Hand out descriptors a declaration could produce

`Operation::descriptor()` returned `&'static OpDescriptor`, and that lifetime
is the whole reason a build-time node is free and a run-time node is
impossible: only a compile-time literal can satisfy it, so no amount of
reading `ops/*.yaml` at startup could ever produce a descriptor the rest of
the application would accept. FR-PLG-2 says a bundled operation and a
third-party plugin are the same kind of thing, differing only in where the
file was found — and a lifetime outsiders cannot meet is exactly the second,
weaker format that requirement forbids.

So a descriptor is now owned and handed out as `Arc<OpDescriptor>`, with `Vec`
where it held `&'static` slices. `Arc` rather than a `&self`-borrowed
reference because the callers want to *keep* it: the develop panel collects
descriptors and then mutates the graph, and a borrow would tie the
descriptor's lifetime to a borrow of the operation it came from, which is the
one thing `&'static` was doing right.

The identifier newtypes deliberately did not follow. `ParamId` is `Copy`, is
compared in `match` arms against generated constants, is a map key in the
sidecar and history, and reaches Slint model rows; an `Arc<str>` there would
cost a refcount on every one of those and would take `match id { EXPOSURE =>
.. }` away from the generated code. They gain an interner instead, which is
honest about its lifetime rather than pretending to one — the set of ids is
bounded by deduplication and is process-lifetime by construction, because the
sidecar on disk names its parameters and an id has to stay resolvable for as
long as any edit naming it can be opened.

No behaviour changes. Every descriptor that was a `static` is a `LazyLock`
initialiser now, `Operation::helpers` borrows from `self` instead of being
`'static` so a future run-time node can own its list, and `Warp` and `Framing`
follow `Operation` so there is one shape rather than two.

The one place a descriptor is read per frame is `compose_full`, which takes
`descriptor().id` to prefix each active operation's uniforms, and `dr-ui`
composes on every frame it draws. That is a dozen atomic increments beside a
composition that is already building several kilobytes of WGSL on the same
call; it is noted at the trait method rather than left for a profiler to find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 19:02:25 +02:00
co-authored by Claude Opus 5
parent 725f7bf77f
commit 0c3b8cb1c4
23 changed files with 772 additions and 490 deletions
+34 -30
View File
@@ -79,6 +79,7 @@
//! whole composition scheme rests on (ARCH §5.6).
use std::fmt::Write as _;
use std::sync::{Arc, LazyLock};
use crate::descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation,
@@ -341,11 +342,12 @@ const fn facet_of(aspect: &'static str, channel: Channel) -> Facet {
/// One channel's ten descriptors, defaulted onto the identity diagonal.
///
/// Written out per point rather than looped because a `ParamDescriptor` has to
/// be `const` to live in a `static`, and a const loop cannot build a slice.
/// Written out per point rather than looped because `concat!` needs literals:
/// the parameter ids are built from the channel's prefix, and a runtime loop
/// has no way to spell `r_p0_x`.
macro_rules! channel_params {
($(($prefix:literal, $channel:expr)),* $(,)?) => {
&[$(
vec![$(
coord(concat!($prefix, "p0_x"), "param.curve.p0_x", 0.0)
.faceted(facet_of("param.curve.p0_x", $channel)),
coord(concat!($prefix, "p0_y"), "param.curve.p0_y", 0.0)
@@ -370,26 +372,28 @@ macro_rules! channel_params {
};
}
static DESCRIPTOR: OpDescriptor = OpDescriptor {
// Both, and this is the case the plural exists for: the master curve is
// tonal and the per-channel curves are chromatic. Filing it under one
// would hide it from half the people looking for it.
attributes: &[Attribute::Tone, Attribute::Colour],
id: ID,
label: LocalizedKey("op.tone_curve"),
// Defaults lie on y = x, so a fresh curve is the identity and the
// operation reports itself inactive — on every channel.
//
// The master's ten come first, and stay first: a frontend addresses a
// point by its offset from the first parameter of the run it is drawing,
// and this is also the order one falling back to sliders reads them in.
params: channel_params![
("", Channel::Master),
("r_", Channel::Red),
("g_", Channel::Green),
("b_", Channel::Blue),
],
};
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
// Both, and this is the case the plural exists for: the master curve is
// tonal and the per-channel curves are chromatic. Filing it under one
// would hide it from half the people looking for it.
attributes: vec![Attribute::Tone, Attribute::Colour],
id: ID,
label: LocalizedKey("op.tone_curve"),
// Defaults lie on y = x, so a fresh curve is the identity and the
// operation reports itself inactive — on every channel.
//
// The master's ten come first, and stay first: a frontend addresses a
// point by its offset from the first parameter of the run it is drawing,
// and this is also the order one falling back to sliders reads them in.
params: channel_params![
("", Channel::Master),
("r_", Channel::Red),
("g_", Channel::Green),
("b_", Channel::Blue),
],
})
});
/// One span of a monotone cubic Hermite spline. Shared by all four curves.
const CURVE_SPAN: Helper = Helper {
@@ -699,8 +703,8 @@ impl ToneCurve {
}
impl Operation for ToneCurve {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
@@ -727,7 +731,7 @@ impl Operation for ToneCurve {
Some(Presentation {
// One entry: there is no second way to draw a tone curve that is
// better than the sliders the frontend falls back to anyway.
widgets: &[WidgetKind::ToneCurve],
widgets: vec![WidgetKind::ToneCurve],
demand: WidgetDemand {
// A point is dragged in x and y together — that is what a
// curve *is*, and a frontend that can only move one axis at a
@@ -739,7 +743,7 @@ impl Operation for ToneCurve {
// leave the other thirty stranded as sliders beneath the plot;
// which of the four it draws at a time is its own affair, and the
// facets are what let it decide without naming a channel.
params: &CURVE_PARAMS,
params: CURVE_PARAMS.to_vec(),
})
}
@@ -910,7 +914,7 @@ mod tests {
// Opening an unedited image must show the image.
let c = ToneCurve::new();
assert!(!c.is_active());
for p in DESCRIPTOR.params {
for p in &DESCRIPTOR.params {
assert_eq!(c.param(p.id), p.default);
}
}
@@ -927,7 +931,7 @@ mod tests {
#[test]
fn every_parameter_id_maps_to_a_point() {
for p in DESCRIPTOR.params {
for p in &DESCRIPTOR.params {
assert!(
ToneCurve::index_of(p.id).is_some(),
"{} does not map to a point",
@@ -1196,7 +1200,7 @@ mod tests {
);
assert_eq!(presentation.choose(|_| false), None);
assert_eq!(presentation.params.len(), DESCRIPTOR.params.len());
for p in DESCRIPTOR.params {
for p in &DESCRIPTOR.params {
assert!(
presentation.params.contains(&p.id),
"{} is not owned by the widget",