LightSpace
A local-first photography workspace for desktop and browser
LightSpace is a shared Uno desktop/WebAssembly photography application: browse a local catalog, develop a photograph, refine its composition and masks, and export a rendered copy while the original bytes stay untouched. A dark workspace combines a library sidebar, photo canvas, filmstrip, histogram and development inspector, powered by GPU-capable SkSL effects and eight reusable C# libraries.

What makes LightSpace special
Familiar if you know Lightroom-style photo workflows — but original, local‑first and written end‑to‑end in C#.
Non-destructive development
Light, white balance, vibrance, curves, eight color bands and effects are stored as settings; original encoded bytes are never modified.
Local photo catalog
Import, search, rate, flag and group photos into albums in a paged grid and filmstrip that runs entirely on your device.
Four-way color grading
Shadow, midtone, highlight and global wheels with luminance, blending and balance controls.
Arbitrary RGB curves
Master, red, green and blue curves with up to 32 points each and linear or shape-preserving smooth interpolation.
Masks and brushes
Radial and linear gradients, luminance and sampled Oklab color ranges, and pressure-aware brush masks with local adjustments.
GPU-capable SkSL effects
Compiled SkSL runtime effects render inside Uno's Skia composition path, with software rendering for tests and export.
XMP sidecars
Import and export standard metadata and a reported Camera Raw subset, with a native extension for full LightSpace round trips.
Content-addressed recovery
Originals stored under SHA-256 keys, atomically published edit manifests and integrity checks on restore.
28 capabilities, 6 areas
Organize
- Import JPEG, PNG, WebP, BMP or the first GIF frame, retaining original bytes
- Paged grid, detail view and filmstrip with 60-photo pages
- Search filenames, captions and keywords
- Ratings, pick/reject flags and albums
- Copy/paste and sync development settings across selected photos
Develop
- Light, relative white balance, vibrance/saturation and monochrome
- Eight-band HSV color mixer with hue, saturation and luminance
- Four-way color grading with blending and balance
- Creative presets, texture/clarity/dehaze approximations and sharpening
- Spatial smoothing, vignette and grain; double-click resets a slider
Curves
- Master, red, green and blue curves with up to 32 points per channel
- Linear or shape-preserving smooth interpolation
- Add, drag and delete points or type Input/Output values
- 2048-entry floating-point lookup cache reused across unrelated edits
Crop, clone and compare
- Free crop rectangles with handles, centered ratios, quarter turns and flips
- Up to 32 feathered clone stamps from an explicit Alt-click source
- Original view and a draggable before/after divider
- Fit, source-pixel 100% zoom and wheel zoom around the pointer
Masks
- Up to eight masks with enable, amount, inversion, rename and duplicate
- Rotatable radial and linear gradients with on-canvas handles
- Luminance ranges and up to five sampled Oklab color pins
- Pressure-aware brush masks with size, feather, flow and density
- Local exposure, contrast, temperature, tint and saturation
Preserve and export
- One transaction per gesture with 100-step undo/redo and named versions
- Export 8-bit sRGB JPEG, PNG or WebP copies or a ZIP of a selection
- Portable .lightspace catalogs with originals, edits, albums and versions
- XMP sidecar import with a compatibility report before applying
- IndexedDB or native recovery with source integrity checks and retry
Run LightSpace right here
Open the live WebAssembly build and develop the demonstration catalog — every edit is non-destructive and stays on your device. Switch viewports, go fullscreen and tick off the guided tour as you explore.
Try LightSpace live
Open the live WebAssembly build and develop the demonstration catalog — every edit is non-destructive and stays on your device.
Real C# · Uno WebAssembly · runs locally in your browser8 libraries you can embed
LightSpace is assembled from independently reusable libraries, each published on NuGet.org. Hover the graph to trace dependencies; click a node to jump to its package, and open a card for install and usage.
LightSpace.Core
Photo state, grading, curves/interpolation, masks, brush samples, geometry and presets
LightSpace.Catalog
Schema migration, serialization, queries and XMP metadata/settings interchange
LightSpace.Editing
Gesture transactions, bounded undo/redo, versions, synchronization and revision-aware recovery
LightSpace.Imaging
Bounded decoding, EXIF orientation, sRGB conversion and fallback artwork
LightSpace.Rendering.Skia
SkSL runtime effects, image cache, curve tables, incremental brush coverage, histogram and export
LightSpace.Storage
Import/export contracts, atomic native recovery store and optional sidecar-picker capability
LightSpace.Controls
Original Uno chrome, icons, sliders, grading/mixer/curve/mask/brush controls and photo canvas
LightSpace.Workbench
Composable workspace, inspectors, commands, dialogs and save UX
8 packages on NuGet.org
All eight libraries are MIT-licensed. Badges show public-feed versions/downloads independently of the source version. Six engine packages target net10.0 without Uno; Imaging and Rendering.Skia use SkiaSharp. Controls and Workbench target desktop/browser with Uno's Skia renderer. Packages are versioned together with .snupkg symbols and SourceLink. Build artifacts do not imply NuGet.org publication. New APIs require packages built from the corresponding source version.
dotnet add package LightSpace.Core --prerelease<PackageReference Include="LightSpace.Core" Version="0.4.1-alpha.1" />
<PackageReference Include="LightSpace.Storage" Version="0.4.1-alpha.1" />
<PackageReference Include="LightSpace.Catalog" Version="0.4.1-alpha.1" />
<PackageReference Include="LightSpace.Imaging" Version="0.4.1-alpha.1" />
<PackageReference Include="LightSpace.Rendering.Skia" Version="0.4.1-alpha.1" />
<PackageReference Include="LightSpace.Editing" Version="0.4.1-alpha.1" />
<PackageReference Include="LightSpace.Controls" Version="0.4.1-alpha.1" />
<PackageReference Include="LightSpace.Workbench" Version="0.4.1-alpha.1" /><!-- Directory.Packages.props -->
<ItemGroup>
<PackageVersion Include="LightSpace.Core" Version="0.4.1-alpha.1" />
<PackageVersion Include="LightSpace.Storage" Version="0.4.1-alpha.1" />
<PackageVersion Include="LightSpace.Catalog" Version="0.4.1-alpha.1" />
<PackageVersion Include="LightSpace.Imaging" Version="0.4.1-alpha.1" />
<PackageVersion Include="LightSpace.Rendering.Skia" Version="0.4.1-alpha.1" />
<PackageVersion Include="LightSpace.Editing" Version="0.4.1-alpha.1" />
<PackageVersion Include="LightSpace.Controls" Version="0.4.1-alpha.1" />
<PackageVersion Include="LightSpace.Workbench" Version="0.4.1-alpha.1" />
</ItemGroup> Published with NuGet Trusted Publishing from GitHub Actions, with symbols (.snupkg) and SourceLink. Release v0.4.1-alpha.1 ↗
Dependencies: Core ← Catalog, Imaging; Imaging ← Rendering.Skia; Catalog + Storage ← Editing; Editing + Rendering.Skia ← Controls; Controls + Storage ← Workbench. Storage has no project dependencies. The host supplies startup and platform storage. Standard Uno input, scrolling and accessibility primitives remain where appropriate.
Dispose workspaces and native caches. Treat snapshots as copy-on-write and originals as read-only. Objects have one logical owner, normally the UI synchronization context. Component API · Reference API · Recovery invariants
UI-independent state includes development, crop, optics, geometry, masks, clones and metadata. Curves support linear or shape-preserving interpolation. Grading, ranges, brushes, transfers, Survey layout and copy identity are independent of UI/rendering. Settings normalize supported values.
dotnet add package LightSpace.Core --prereleaseBasic usage
Key types: PhotoState, DevelopSettings, CropSettings, CloneSpot, GeometrySettings, LensCorrectionSettings, GeometryProjection, ProjectiveTransform, LensMapping, WhiteBalanceEstimator, PointCurve, ChannelCurves, CompiledPointCurve, ColorGradingSettings, GradingTone, ColorBand, LocalMask, ColorRangeSettings, BrushStroke, BrushStrokeBuilder, CatalogDocument, PhotoDocument, VirtualCopyNames, Album, NamedVersion, BuiltInPresets, EditSettingsGroup, EditSettingsTransfer, PhotoNavigationState, SurveyLayout.
using LightSpace.Core;
var state = new PhotoState
{
Develop = BuiltInPresets.All.First(p => p.Name == "Golden hour").Settings with { Exposure = .3f },
Crop = new CropSettings(Left: .05f, Top: .05f, Right: .95f, Bottom: .9f),
Geometry = new GeometrySettings { Rotate = 1.5f, ConstrainCrop = true },
Optics = new LensCorrectionSettings(Distortion: 12),
Masks = [new LocalMask { Name = "Sky", Kind = MaskKind.Linear, Y = .2f, Exposure = -.4f }],
Rating = 4, Flag = PhotoFlag.Pick, Keywords = ["alps", "sunset"]
}.Normalize();
var develop = state.Develop.Set(nameof(DevelopSettings.Contrast), 20);
var curve = new PointCurve { Points = [new(0, 0), new(.25f, .18f), new(.75f, .82f), new(1, 1)] };
float midtone = curve.Compile().Evaluate(.5f);
(int width, int height) = state.Crop.OutputSize(6000, 4000);
var transfer = new EditSettingsTransfer(state, EditSettingsGroup.Light | EditSettingsGroup.WhiteBalance);
var target = transfer.Apply(new PhotoState { Caption = "Retained target metadata" });Picker, download and recovery contracts. FileRecoveryStore stages SHA-256 originals and replaces a manifest after required sources exist. No UI or project dependencies.
dotnet add package LightSpace.Storage --prereleaseBasic usage
Key types: IWorkspaceStorage (OpenImagesAsync/OpenCatalogAsync/SaveAsync/ReadRecoveryAsync/WriteRecoveryAsync), WorkspaceFile, IRecoveryStore (ReadManifestAsync/ReadBlobAsync/CommitAsync), FileRecoveryStore, RecoveryWrite, RecoveryBlob, RecoveryKeys, optional ISidecarStorage.
using LightSpace.Storage;
IRecoveryStore store = new FileRecoveryStore(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MyApp", "recovery"));
// manifestJson must be a valid recovery manifest describing these sources.
byte[] original = File.ReadAllBytes("mountains.jpg");
string key = RecoveryKeys.Hash(original);
await store.CommitAsync(new RecoveryWrite(manifestJson, [key], [new RecoveryBlob(key, original)]));
string? manifest = await store.ReadManifestAsync();
byte[]? restored = await store.ReadBlobAsync(key);Normally RecoveryPersistence builds these writes. Arbitrary text is not a valid manifest.
Validated serialization, migration, search/filter/sort, copy families, manifests and XMP. Schema 6 accepts catalogs 1–5 and hydrates sources from validated masters. Native XMP retains processing, not family identity. Depends on Core; no UI.
dotnet add package LightSpace.Catalog --prereleaseBasic usage
Key types: CatalogSerializer (Serialize/Deserialize/Validate/SerializeSettings/DeserializeSettings), VirtualCopyCatalog, PhotoQuery, PhotoSort, PhotoKindFilter, XmpSidecar, XmpImportResult, XmpExportResult, RecoveryManifest, RecoverySourceReference.
using LightSpace.Catalog;
using LightSpace.Core;
CatalogDocument catalog = CatalogSerializer.Deserialize(File.ReadAllText("catalog.json"));
var picks = new PhotoQuery(Text: "alps", MinimumRating: 3,
Flag: PhotoFlag.Pick, Sort: PhotoSort.Rating).Execute(catalog);
var copies = new PhotoQuery { Kind = PhotoKindFilter.VirtualCopies }.Execute(catalog);
if (picks.Count > 0)
{
var photo = picks[0];
XmpImportResult imported = XmpSidecar.Import(File.ReadAllText("IMG_0042.xmp"), photo.State);
foreach (var warning in imported.Warnings) Console.WriteLine(warning);
// Interactive hosts should present warnings and apply through EditorSession.
photo.State = imported.State;
File.WriteAllText("IMG_0042.xmp", XmpSidecar.Export(photo.State).Xml);
}
File.WriteAllText("catalog.json", CatalogSerializer.Serialize(catalog));JPEG, PNG, WebP, BMP and first-GIF-frame decoding, EXIF orientation, downscaled sRGB previews and procedural landscapes. Limits: 64 MiB encoded, 100 megapixels decoded. Depends on Core/SkiaSharp; no UI.
dotnet add package LightSpace.Imaging --prereleaseBasic usage
Key types: PhotoCodec.Import(name, bytes), PhotoCodec.Decode(bytes, maxDimension), SamplePhotos.CreateCatalog(), SamplePhotos.Create(seed).
using LightSpace.Core;
using LightSpace.Imaging;
using SkiaSharp;
PhotoDocument photo = PhotoCodec.Import("mountains.jpg", File.ReadAllBytes("mountains.jpg"));
using SKImage preview = PhotoCodec.Decode(photo.Original, maxDimension: 1024);
CatalogDocument samples = SamplePhotos.CreateCatalog();Development, curves, grading, masks, clones and color ranges use SkSL effects. Optical presentation composes with development; projective geometry is a draw matrix. Backend execution is host-dependent GPU/software. Histograms, Auto tone, bounded sampling and export are included. Matching immutable source arrays share decoding independently of processing. Depends on Imaging; no UI framework.
dotnet add package LightSpace.Rendering.Skia --prereleaseBasic usage
Key types: PhotoRenderer (Prepare/Draw/CreateShader/Export/CalculateHistogram/Auto/SampleSource/ReleasePhoto/Statistics/Presentation), Histogram, PhotoTransform, GeometryMapping, ClippingIndicators, ToneLookupCache, BrushCoverageCache, RendererStatistics with its six-field API plus CachedSources/SharedSourceHits.
using LightSpace.Core;
using LightSpace.Editing;
using LightSpace.Imaging;
using LightSpace.Rendering.Skia;
using SkiaSharp;
var photo = PhotoCodec.Import("architecture.jpg", File.ReadAllBytes("architecture.jpg"));
var session = new EditorSession(new CatalogDocument { Photos = [photo], ActivePhoto = photo.Id });
session.Edit("Correct perspective and contrast", state => state with
{
Develop = state.Develop with
{
Exposure = .3f,
Channels = new ChannelCurves
{
Master = new PointCurve { Points = [new(0, 0), new(.25f, .18f), new(.75f, .82f), new(1, 1)] }
}
},
Geometry = new GeometrySettings { Vertical = -18, Rotate = 1.5f, ConstrainCrop = true },
Optics = new LensCorrectionSettings(Distortion: 12, Vignetting: 8)
});
using var renderer = new PhotoRenderer();
File.WriteAllBytes("architecture-edited.jpg",
renderer.Export(photo, SKEncodedImageFormat.Jpeg, quality: 92, maxDimension: 4096));
session.Undo(); // Original bytes remain untouched.EditorSession coalesces completed gestures into transactions. Previews change visible state; commit adds history and cancel restores the opening state. Versions, selective sync, frozen references, copy deltas and committed-revision recovery are included. Depends on Catalog/Storage; no UI.
dotnet add package LightSpace.Editing --prereleaseBasic usage
Key types: EditorSession (Catalog/Active/Selection/Edit/BeginGesture/Preview/CommitGesture/CancelGesture/Undo/Redo/SaveVersion/SyncSelected/ApplySettings/CreateAlbum/Changed), CreateVirtualCopies/RenameVirtualCopy/RemoveVirtualCopies operations, ReferencePhotoSnapshot, SurveySelection, RecoveryPersistence, RecoveryCoordinator.
using LightSpace.Core;
using LightSpace.Editing;
using LightSpace.Storage;
var session = new EditorSession(catalog);
session.Changed += () => Console.WriteLine($"Revision {session.Revision}");
session.BeginGesture();
foreach (var value in new[] { .1f, .2f, .35f })
session.Preview(state => state with { Develop = state.Develop with { Exposure = value } });
session.CommitGesture("Exposure");
session.SaveVersion("Bright");
session.SyncSelected(); // All processing; target metadata retained.
session.Undo();
session.SyncSelected(EditSettingsGroup.Light | EditSettingsGroup.ColorGrading);
var copy = session.CreateVirtualCopies([session.Active!.Id]).Single();
session.RenameVirtualCopy(copy.Id, "Warm alternative");
session.Edit("Alternative exposure", state => state with
{
Develop = state.Develop with { Exposure = .8f }
});
session.RemoveVirtualCopies([copy.Id]);
session.Undo(); // Same copy identity/settings; original never deleted.
var persistence = new RecoveryPersistence(new FileRecoveryStore("recovery"));
using var recovery = RecoveryCoordinator.Incremental(session, persistence, initiallySaved: false);
await recovery.FlushAsync();Original Uno active/reference/Survey views, settings selection, sliders, curves, grading/mixer/range/mask editors, optics/geometry, interactive histogram, panel grips, thumbnails and chrome. Editors expose preview/commit/cancel without owning transaction policy. Brush settings affect new strokes. Depends on Editing/Rendering.Skia; requires Uno's Skia renderer.
dotnet add package LightSpace.Controls --prereleaseBasic usage
Key types: PhotoViewport, ReferencePhotoView, PhotoSurveyView, SurveyCard, SettingsTransferEditor, AdjustmentSlider, PointCurveEditor, ColorGradingEditor, ColorMixerEditor, ColorRangeEditor, MaskSettingsEditor, GeometryEditor, OpticsEditor, BrushSettingsEditor, HistogramView, PanelResizeGrip, PhotoCard, PhotoThumbnail, ThumbnailCache, PanelSection, LightButton, Theme.
using LightSpace.Controls;
using LightSpace.Editing;
using LightSpace.Rendering.Skia;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
var session = new EditorSession(catalog);
var renderer = new PhotoRenderer();
var viewport = new PhotoViewport(session, renderer);
var exposure = new AdjustmentSlider("Exposure", -5, 5, 0, .01);
exposure.ValueChanged += value => session.Preview(s => s with { Develop = s.Develop with { Exposure = value } });
exposure.ValueCommitted += () => session.CommitGesture("Exposure");
exposure.GestureCanceled += session.CancelGesture;
var curves = new PointCurveEditor { Value = session.Active!.State.Develop.Channels };
curves.Previewed += value => session.Preview(s => s with { Develop = s.Develop with { Channels = value } });
curves.Committed += () => session.CommitGesture("RGB point curve");
curves.Canceled += session.CancelGesture;
var geometry = new GeometryEditor { Value = session.Active.State.Geometry };
geometry.Previewed += value => session.Preview(s => s with { Geometry = value });
geometry.Committed += () => session.CommitGesture("Geometry");
geometry.Canceled += session.CancelGesture;
session.ViewChanged += () =>
{
if (session.Active is not { } active) return;
exposure.Value = active.State.Develop.Exposure;
curves.Value = active.State.Develop.Channels;
geometry.Value = active.State.Geometry;
};
var inspector = new StackPanel { Width = 300, Children = { exposure, curves, geometry } };
var root = new Grid { Background = Theme.Background };
root.ColumnDefinitions.Add(new ColumnDefinition());
root.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
root.Children.Add(viewport);
var scroll = new ScrollViewer { Content = inspector };
Grid.SetColumn(scroll, 1); root.Children.Add(scroll);
window.Content = root;
window.Closed += (_, _) => { viewport.Dispose(); renderer.Dispose(); };See reference embedding and Survey for multi-view wiring and releasing session-local processing identities. A standalone view does not dispose its external renderer.
Complete workspace control with catalog/grid/filmstrip, editing/optics/geometry, references, Survey, copy manager, settings transfer, tools, versions, dialogs, shortcuts, resizing and recovery. Depends on Controls/Storage; requires Uno. ISidecarStorage is optional.
dotnet add package LightSpace.Workbench --prereleaseBasic usage
Key types: StudioView(EditorSession, IWorkspaceStorage, recoveryLoaded, RecoveryPersistence?) exposes Session/Viewport/Recovery/SetGrid/ChooseTool/SetStatus/SaveRecoveryAsync, UnsavedChangesChanged, opt-in DiagnosticsChanged and ProtectExistingRecovery.
using LightSpace.Editing;
using LightSpace.Imaging;
using LightSpace.Storage;
using LightSpace.Workbench;
IWorkspaceStorage storage = new MyWorkspaceStorage(); // Host picker/export/recovery adapter.
var persistence = storage is IRecoveryStore store ? new RecoveryPersistence(store) : null;
var catalog = persistence is null ? null : await persistence.RestoreAsync();
var studio = new StudioView(new EditorSession(catalog ?? SamplePhotos.CreateCatalog()),
storage, recoveryLoaded: catalog is not null, persistence: persistence);
window.Content = studio;
window.Closed += (_, _) => studio.Dispose();Catch restore failures and protect unreadable prior data rather than replacing it automatically; the supplied application host implements that safeguard.
From engine to workbench
Layers build bottom‑up: each one depends only on those beneath it.
Immutable photo state, adjustments, curves, masks and geometry with no Uno dependency.
LightSpace.CoreVersioned catalog serialization and XMP, bounded Skia decoding, and platform-independent storage contracts.
LightSpace.CatalogLightSpace.ImagingLightSpace.StorageGesture transactions, undo and versions alongside SkSL development, masks, histogram and export.
LightSpace.EditingLightSpace.Rendering.SkiaOriginal photography controls composed into a reusable workspace for browser and desktop hosts.
LightSpace.ControlsLightSpace.WorkbenchEmbed, script, extend
using LightSpace.Core;
using LightSpace.Editing;
using LightSpace.Imaging;
using LightSpace.Rendering.Skia;
using SkiaSharp;
var photo = PhotoCodec.Import("mountains.jpg", File.ReadAllBytes("mountains.jpg"));
var session = new EditorSession(new CatalogDocument { Photos = [photo], ActivePhoto = photo.Id });
session.Edit("Refine contrast", state => state with
{
Develop = state.Develop with
{
Exposure = .3f,
Channels = new ChannelCurves
{
Master = new PointCurve { Points = [new(0, 0), new(.25f, .18f), new(.75f, .82f), new(1, 1)] }
}
}
});
using var renderer = new PhotoRenderer();
File.WriteAllBytes("mountains-edited.jpg",
renderer.Export(photo, SKEncodedImageFormat.Jpeg, quality: 92, maxDimension: 4096));
session.Undo(); // Original encoded bytes are untouched.git clone https://github.com/wieslawsoltes/LightSpace.git
cd LightSpace
python3 scripts/fetch-assets.py
dotnet workload install wasm-tools --skip-manifest-update
# Model, processing, lifetime, recovery, XMP and performance checks
dotnet run --project tests/LightSpace.Engine.Tests -c Release
# Shared native desktop host
dotnet run --project src/LightSpace.App -f net10.0-desktop \
-p:LightSpaceDesktopOnly=true
# Actual Uno WebAssembly publication
dotnet publish src/LightSpace.App -f net10.0-browserwasm -c Release \
-o artifacts/publish -p:WasmShellWebAppBasePath=/LightSpace/
python3 scripts/collect-site.py artifacts/publish artifacts/site
python3 scripts/serve-site.py --directory artifacts/site --port 4173File formats
Keyboard essentials
Continuously verified
Each push runs through GitHub Actions. The public browser build is deployed only from successful pipelines.
build.ymldesktop.ymlpages.ymlrelease.ymlslider-diagnostic.ymlToolchain
Third‑party NuGet packages
git clone https://github.com/wieslawsoltes/LightSpace.git
cd LightSpace
dotnet workload install wasm-tools
dotnet run --project src/LightSpace.App -f net10.0-browserwasmWhat it doesn’t claim
The project documents its limits as carefully as its features.
- Independent early-stage software, not a pixel-identical or feature-complete Lightroom replacement
- No RAW/DNG/HEIF/TIFF decoding, AI tools, HDR/panorama merging or calibrated camera/lens correction
- Output is 8-bit sRGB up to an 8192 px long edge; source EXIF/IPTC is not embedded in copies
- Brush mask coverage is capped at a 1024-pixel long edge, even for export
- Recovery is local and unencrypted — not cloud sync or a substitute for original-file backups