Design effects - orlandomenke/Lightbox GitHub Wiki
Written 2026-08-12. Decisions taken provisionally — the owner was prompted and the prompt went unanswered, so per Q66 the four pivotal choices are restated at the top of the pull request that carries this file, where they can be reversed in a sentence. Everything below assumes the recommendations held.
This is the design for the roadmap's [?] Non-destructive filters, widened the
way the request widened it: Photoshop-style filters and animation effects,
with a node system in the future that these must slot into rather than be
replaced by, possibly project-bound, and — hard requirement — decoupled from
MainWindow/MainViewModel, which are already the two hottest files in
HOTSPOTS.md.
There is one effect model, not two. An effect is a pure function:
(input image, frame index, params) → output image
Every parameter can be a constant or carry keys with easing — the same
key-plus-Easing vocabulary CameraKey already uses, so a camera move, a
drawing inbetween and an animated blur are all described in one language. A
"Photoshop filter" is simply an effect none of whose parameters have keys.
The two lanes the request floated — photoshop and animation effects — are real, but they are a presentation difference, not an architecture: the picker groups effects by a tag on the definition (a grade and a blur read as filters, a wiggle and a flicker read as animation), and any effect can be keyed regardless of which shelf it sits on. That is the every feature is reachable rule applied here: the shelf decides what is in front of you, never what the parameter can do.
Why not two lanes with a shared core: it duplicates the registry, the UI and the serialization for a distinction that evaporates the moment an artist keys a filter — and it hands the future node system two node kinds where one would do. Why not static-only first: retrofitting time-variance into a shipped record is a migration; shipping the record time-capable with no keys written costs nothing (a constant param serializes as one number, see below).
New files in src/Lightbox.Core/Effects/ — no rendering, no UI, per the layer
rules:
EffectStack { List<EffectUse> Uses }
EffectUse { string Id; string Kind; bool Enabled = true;
Dictionary<string, EffectParam> Params }
EffectParam { double Value; List<EffectKey>? Keys } // Keys null = constant
EffectKey { int Frame; double Value; Easing Ease }-
Kindis a string id, resolved through a registry at render time ("blur.gaussian","grade.levels"). An unknown kind — a document from a newer build — is preserved on save and rendered as identity, flagged in the UI, never dropped. Forward compatibility is what a string id buys over an enum, and the brush tip registry already made this trade the same way. -
EffectParam.Keysis nullable, so an unkeyed parameter writes"radius": {"value": 4}and nothing else. This is the optional means absent rule at the parameter level, and the serialization test below is what keeps it true. - Evaluation lives in
EffectOps.At(param, frame)— the exact shape ofCameraOps.At: hold outside the authored range, ease between keys, pure function of its inputs.
Two nullable properties, both absent until authored — the camera's rule,
stated in Layer.OmitFromExport's own doc comment and enforced the same way:
| Attachment | Affects | Typical use |
|---|---|---|
Layer.Effects (EffectStack?) |
that layer's baked output, before blend | blur one layer, glow the ink layer |
Scene.Effects (EffectStack?) |
the whole composite, before the camera | grade, grain, vignette — where most animation effects live |
A document that never touches effects serializes byte-identically to today.
Assert.DoesNotContain("\"effects\"", json) on a default document goes in the
same commit as the record — that is the cheap half of the "optional has two
halves" lesson, applied before it can be missed.
Per-frame attachment is deliberately not in v1. A one-frame effect is a one-frame key range on a layer or scene stack; a per-cel stack triples the attachment surface for a case keying already covers.
-
The record is the document (inv. 1). Effects are parameters, pixels are derived. There is no destructive apply in v1; if a flatten to strokes / bake command ever exists it is an explicit rewrite of the record, not a render-time behaviour.
-
No randomness (inv. 2). Grain, noise, wiggle and anything stochastic seeds from
Hash01over document position and frame index — never an RNG, never a clock, never an iteration index. Same reason as brushes: a re-render, an export and an AI inbetween must agree. And the flicker rule from the front page applies doubly here: an effect whose noise is not frame-stable by choice (film grain wants to move) must move because its seed includes the frame, not because anything drifted. -
Settings that affect pixels live in the record (inv. 4). All of an effect's inputs are in
EffectUse.Params. Nothing reads a preference at render time. -
Bounded work (inv. 6). Every effect declares its reach — the maximum distance a pixel's output can depend on input:
Reach class Examples Repaint consequence point (0 px) levels, HSL, curves dirty region unchanged kernel (finite) blur, glow, sharpen dirty region inflated by reach, exactly as brush strokes already inflate by their own reach global large distortions whole affected surface re-runs; the effect is badged costly in the picker, the BrushCostOfprecedentReach is derived from the params (a blur's reach is its radius), so the badge cannot lie — also the
BrushCostOfprecedent. -
Scale the surface, never the geometry (inv. 7). Reach and any position-seeded noise are declared in document pixels and mapped by the canvas transform, the same trap
ApplyGranulationalready documented: a 2× export must not re-roll the grain or halve the blur.
The pipeline gains one seam, in two places:
strokes → layer bake → [layer effect pass] → blend/composite → [scene effect pass] → camera → view
- The pass itself lives in
src/Lightbox.Raster/Effects/besideTileCompositor— the model never renders, the App never touches pixels.EffectRegistrymaps kind ids to implementations there. -
CPU first.
docs/DESIGN-gpu-compositing.mdalready owns the question of what moves to the GPU and why; effects add entries to that ledger rather than a parallel answer. The pass is written against the same surface-in/surface-out boundary GPU compositing uses, so moving an effect to a Skia runtime shader later changes an implementation, not the seam. - Caching: a layer's effect output is cached keyed on (layer content version, frame, params hash). A static filter on a held cel therefore costs once, not per repaint; only keyed params invalidate per frame. This is what keeps 12 fps playback honest, and it gets a performance-tagged budget test the day the pass lands, not after.
What is project-bound is the named stack — an effect preset saved as its
own file in the project, filed by folder, exactly the shape sheets took when
Q25 was re-answered. An application (Layer.Effects, Scene.Effects) stays in
the document it affects, because it is part of that document's record
(invariant 1 does not survive a render that depends on a file outside the
document unless the reference is resolved and inlined on flatten — the same
condition Q28 recorded).
v1 ships the record able to hold a preset reference but resolves it eagerly on apply (copy, not link). Live-linked presets — edit the preset, every user updates — are a node-system behaviour and wait for it.
Nothing in v1 builds a node graph, and everything in v1 is shaped so the graph subsumes it rather than replaces it:
-
EffectUseis already a node: typed params, one image in, one image out, pure, deterministic. A stack is the degenerate graph — a linear chain. - The evaluation signature stays pure (
Render(input, frame, params)), so a future graph evaluator calls the same implementations the stack pass calls. - The graph, when it comes, is a project-level document that can reference pages and animations — which is why presets are project files now: the graph file lands beside them, and a stack imports into it as one chain of nodes.
- What is deliberately not pre-built: multi-input nodes, branching, and the graph editor. Building those speculatively is how a node system ships twice.
The request's hard constraint, and the part with a registry checklist:
-
Own view model, own docker.
EffectsViewModel+ an effects docker in their own files;MainViewModelgains only the registration line and the active-selection context it already exposes. No effect logic, no effect properties, no effect commands onMainViewModel— that is the review bar for every diff in this area, checkable by looking at the diff's file list. -
Landing checklist (the land the places it shows up table, resolved in
advance): shortcuts through
ShortcutMap; per-document options in the document window scope, not Configure; presets survive save/reuse as project files; the docker registers in workspace defaults; and the MCP surface getseffects.*operations, because an agent that can paint should be able to key a blur.
Small, one of each reach class, each proving a different seam: Gaussian blur (kernel; the reach machinery), levels (point; the cheap path), HSL (point; colour), film grain (point but seeded; invariant 2 under animation), vignette (point with geometry params; keying reads well). Every further effect is catalogue work, not architecture.
- Record +
EffectOps+ serialization tests (absent-until-used proven). - Raster pass + registry + blur and levels, with the cache and its budget test.
- Docker + view model, wired through the landing checklist.
- Scene stack, grain/HSL/vignette, keying UI on the timeline.
- Presets as project files.
Each is one branch. The roadmap item stays [?] until step 2 gives it
evidence anchors to name.