Files
DarkRoom/core/dr-film
dtourolleandClaude Opus 5 e14bc34a9e
Build and test / Desktop (Linux) (push) Successful in 19m6s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Failing after 26s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 33m7s
Ask which frame this was taken on, because grain is enlargement
A crystal is a fixed size in micrometres. How grainy a photograph looks is
therefore not a property of the emulsion alone -- it is film size against
output size, and the frame is the half a digital file cannot supply.

This assumed 35 mm for everything. The same emulsion on 4x5 averages about
3,800 crystals into the pixel that holds 300 on 35 mm, so it renders roughly
3.5 times smoother at the same print; every large-format photograph was being
rendered as grainy as a half-frame.

`Format` now carries the real image widths -- the gate, not the nominal inches,
since a "4x5" exposes about 121 mm -- and the film node asks for it. It is a
genuinely fixed list, unlike the stocks, so it is a declared `enum` parameter
and gets its control, its sidecar entry and its undo step for nothing.

It is also the first enum in the develop chain, and it broke two tests by
being one. A row has to compare equal to itself across two builds or
`sync_rows` replaces it on every parameter event -- destroying the elements
built from it, including whichever TouchArea holds the current gesture, so the
format picker would have fought every slider drag in the panel. `ModelRc`
compares by identity and the row built a fresh choices model each call.

`no_choices` already shares one empty model for exactly this reason, and the
build site already said "see no_choices for why the identity matters". The fix
follows it: memoise the model per variant list. Curve rows solve the same
problem the other way, writing values through the existing model, which is not
needed here -- a variant list is fixed at compile time, so one model can serve
forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:47:19 +02:00
..

Film stocks

One file per stock in profiles/. Adding a stock is adding a file — no code change, no shader, no new operation — for the same reason dr-decode's base curves work that way: under the GPLv3 a stock should be contributable without a release.

What a profile is

Three measured tables, all of them published in the manufacturer's datasheet:

Field What it decides
log_sensitivity what each emulsion layer sees, per wavelength
density_curves contrast, latitude, and where the stock clips
dye_density what the developed stock looks like, per wavelength
base_density the support: film base, and a colour negative's orange mask

Plus kind (negative or positive), support (film or paper), and the two illuminants the data is referenced to. A print paper is a stock like any other; support exists so an interface can offer papers separately, not because the renderer treats them differently.

Why it is not a LUT

Because the parameters stay physical. Opening up a stop moves the picture along the film's own characteristic curve — toe, shoulder and all — instead of scaling a number somebody baked at one exposure. A scanned negative comes out orange and inverted because that is what a negative is, and it becomes a photograph when a paper profile prints it, exactly as it would in a darkroom.

The data cost runs the other way from a LUT collection too: a stock is about 17 kB of measurements, where one HaldCLUT is roughly 800 kB of one person's grade.

How it runs

The spectral chain reduces to three tables, and the reduction is exact where it matters — see src/bake.rs for the argument:

  1. A 3×3 matrix, linear sRGB to the three layers' exposure. Exact, not an approximation: the reconstructed scene spectrum is linear in the sRGB triple, so the integral collapses into nine numbers.
  2. Three 1D curves, log exposure to density, sampled at 256 points.
  3. One 32³ lookup, density to linear sRGB — dye absorption, the print through the negative, the paper, the viewing illuminant and the chromatic adaptation, all of which take exactly three numbers in.

Per pixel that is a matrix multiply, three curve taps and one texture fetch. Splitting 2 from 3, rather than baking one LUT over exposure, is measured rather than assumed: the curve carries all the sharp shape and the dye mixing is smooth, so folding the curve into the 3D lookup would need it three times larger for the same error. At 32³ the worst interpolation error is about 0.003 in linear sRGB, below one 8-bit code value, and there is a test that says so.

Adding a stock

If spektrafilm has it, add its name to STOCKS in tools/film-profiles/convert.py and re-run it. Otherwise write the YAML by hand from the datasheet; the loader validates the table lengths and says which file and field is wrong.

Either way, list it in BUILT_IN in src/lib.rs to compile it in — or drop it in the profile directory at runtime, which is the path meant for stocks that ship separately from the binary.

Provenance

The shipped profiles are converted from spektrafilm by Andrea Volpato, licensed CC BY-SA 4.0. See profiles/LICENSE-PROFILES.txt for the licence and profiles/CHANGELOG.txt for what the conversion changed and what it deliberately did not.

The sRGB reflectance basis is Mallett & Yuksel (2019); the observer is the CIE 1931 2°.