Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions docs/adr/2026-07-16-primary-visual-product.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# ADR: Primary Visual Product — the Project's Face Is a Bus Convention

- **Status:** Accepted
- **Date:** 2026-07-16
- **Deciders:** Photomancer
- **Supersedes:** the informal `channel.name == "visual.out"` check in
`ProjectController::ui_bus_view` (the bus pane's PRIMARY badge)
- **Superseded by:** None

## Context

Several surfaces need one answer to "what do I render to represent this
project?": the Studio editor's default preview, the bus pane's PRIMARY
badge, home-gallery card imagery (the GPU live-gallery plan's
PreviewHost and its static snapshot fallback), and any future headless
thumbnailer. Today the answer is folklore: the default visual-output
policy targets `bus:visual.out` (ADR 2026-07-09), fixtures sample it,
and the bus pane hardcodes the channel name for its badge — a
coincidence of defaults, not a contract. Consumers that re-derive
"which product is the face" will disagree at the edges (multiple
providers, no provider, provider that fails to resolve).

## Decision

**The project's primary visual is the resolved value of
`bus:visual.out`: the product produced by the channel's
highest-priority provider.**

- The channel name is a constant in the well-known registry
(`lpc-model/src/bus/well_known.rs`, `PRIMARY_VISUAL_CHANNEL`) — the
one place the name is written.
- **Resolution is the engine's, not the client's.** The binding-graph
probe (ADR 2026-07-06) already resolves each channel's value by
provider priority; for `visual.out` that value is a
`LpValue::Product(ProductRef::Visual(..))`. Clients read the resolved
answer instead of re-deriving it from provider lists —
`ProjectController::primary_visual_product()` is the client-side
helper, resolved from the cached graph with **no new wire surface**.
- **Determinism:**
- No provider, unresolvable value, or a non-visual product → `None`:
the defined empty state (consumers show their placeholder; nothing
is invented).
- Multiple providers → binding priority (authored > default, per the
existing `BindingPriority` ordering).
- Equal priority → the binding index's registration order decides,
which the graph probe reports deterministically
(`Reverse(priority)`, then `BindingRef`). This tie-break is now
contract, not accident.
- `control.out` is the analogous convention for control-first projects.
It is declared here for symmetry and left unconsumed: no
control-product preview surface exists yet.

## Consumers

- **Bus pane PRIMARY badge** — same constant, same meaning.
- **Studio always-live preview** — the primary product is subscribed
whenever a project is open (independent of node focus), so the
project's face is always streaming somewhere stable.
- **Gallery card imagery** — the GPU live-gallery plan's PreviewHost
presents the primary product per leased card; its save-time snapshot
fallback captures the same product. (See
`Planning/lp2025/2026-07-16-gpu-live-gallery-cards/`.)
- Future: headless thumbnailers, device-side "what's playing" surfaces.

## Consequences

- Exactly one definition of "the project's face"; surfaces cannot
drift.
- Projects whose primary output is intentionally not `visual.out`
(control-first installations) have no visual face by definition —
their preview surfaces show the empty state until a `control.out`
preview story exists.
- The helper is only as fresh as the cached binding graph; consumers
needing synchronous freshness after an edit should treat `None` /
stale as "keep the previous frame", never as an error.
40 changes: 30 additions & 10 deletions lp-app/lpa-studio-core/src/app/project/node/node_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ impl NodeController {
&SlotEditJoin::empty(),
&|_| Vec::new(),
&|_, _| None,
None,
)
}

Expand All @@ -187,12 +188,14 @@ impl NodeController {
edits: &SlotEditJoin<'_>,
extra_config: &impl Fn(NodeId) -> Vec<UiConfigSlot>,
asset_editor: &impl Fn(&NodeController, &UiSlotAsset) -> Option<UiAssetEditor>,
always_live: Option<&UiProductRef>,
) -> UiNodeView {
let children = self.ui_children_with_product_previews(
product_preview,
edits,
extra_config,
asset_editor,
always_live,
);
let dirty = self.own_slots_dirty_summary(edits)
+ children
Expand All @@ -213,8 +216,12 @@ impl NodeController {
header = header.with_detail(detail);
}

let mut sections =
self.ui_sections_with_product_previews(product_preview, edits, extra_config);
let mut sections = self.ui_sections_with_product_previews(
product_preview,
edits,
extra_config,
always_live,
);
self.embed_asset_editors(&mut sections, asset_editor);
let mut view = UiNodeView::new(header, vec![UiNodeTab::main(sections)])
.with_node_id(self.address.to_string())
Expand Down Expand Up @@ -412,6 +419,7 @@ impl NodeController {
product_preview: &impl Fn(&UiProductRef) -> Option<UiProductPreview>,
edits: &SlotEditJoin<'_>,
extra_config: &impl Fn(NodeId) -> Vec<UiConfigSlot>,
always_live: Option<&UiProductRef>,
) -> Vec<UiNodeSection> {
let mut products = Vec::new();
let mut produced_values = Vec::new();
Expand Down Expand Up @@ -443,12 +451,18 @@ impl NodeController {
product.preview = preview;
has_cached_preview = true;
}
product.tracking =
if base_tracking == UiProductTrackingState::Untracked && has_cached_preview {
UiProductTrackingState::Paused
} else {
base_tracking
}
// The primary visual is always presented live: it is
// subscribed project-wide regardless of node focus (ADR
// 2026-07-16-primary-visual-product, M6 P3).
let is_always_live =
product.product.is_some() && product.product.as_ref() == always_live;
product.tracking = if is_always_live {
UiProductTrackingState::Tracking
} else if base_tracking == UiProductTrackingState::Untracked && has_cached_preview {
UiProductTrackingState::Paused
} else {
base_tracking
}
}
sections.push(UiNodeSection::ProducedProducts(products));
}
Expand Down Expand Up @@ -492,6 +506,7 @@ impl NodeController {
edits: &SlotEditJoin<'_>,
extra_config: &impl Fn(NodeId) -> Vec<UiConfigSlot>,
asset_editor: &impl Fn(&NodeController, &UiSlotAsset) -> Option<UiAssetEditor>,
always_live: Option<&UiProductRef>,
) -> Vec<UiNodeChild> {
self.children
.iter()
Expand All @@ -505,14 +520,19 @@ impl NodeController {
view.summary = child.status.detail.clone();
view.focused = child.state.focused;
view.action = Some(node_focus_action(child));
view.sections =
child.ui_sections_with_product_previews(product_preview, edits, extra_config);
view.sections = child.ui_sections_with_product_previews(
product_preview,
edits,
extra_config,
always_live,
);
child.embed_asset_editors(&mut view.sections, asset_editor);
view.children = child.ui_children_with_product_previews(
product_preview,
edits,
extra_config,
asset_editor,
always_live,
);
view.dirty = child.own_slots_dirty_summary(edits)
+ view
Expand Down
160 changes: 159 additions & 1 deletion lp-app/lpa-studio-core/src/app/project/project_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,14 +310,36 @@ impl ProjectController {
.and_then(|value| value.value.as_ref())
.map(format_lp_value),
value_error: channel.value.as_ref().and_then(|value| value.error.clone()),
primary_visual: channel.name == "visual.out",
primary_visual: channel.name == lpc_model::PRIMARY_VISUAL_CHANNEL,
writers: channel.providers.iter().filter_map(site).collect(),
readers: channel.consumers.iter().filter_map(site).collect(),
})
.collect();
Some(crate::UiBusView { channels })
}

/// The project's **primary visual product**: the resolved value of
/// `bus:visual.out` (ADR 2026-07-16-primary-visual-product).
///
/// Reads the engine's answer off the cached binding graph — the probe
/// already resolves the channel by provider priority, so this never
/// re-derives precedence client-side. `None` is the defined empty
/// state: no graph yet, no provider, an unresolved value, or a
/// non-visual product on the channel.
pub fn primary_visual_product(&self) -> Option<UiProductRef> {
let graph = self.binding_graph()?;
let channel = graph
.channels
.iter()
.find(|channel| channel.name == lpc_model::PRIMARY_VISUAL_CHANNEL)?;
let value = channel.value.as_ref()?.value.as_ref()?;
let lpc_model::LpValue::Product(product) = value else {
return None;
};
matches!(product, lpc_model::ProductRef::Visual(_))
.then(|| UiProductRef::from_product_ref(*product))
}

/// Channels the binding picker offers: every channel observed in the
/// effective binding graph plus the well-known registry, well-known
/// first (M4). Kinds come from the registry, falling back to the wire
Expand Down Expand Up @@ -389,6 +411,7 @@ impl ProjectController {

/// Project root node controllers into node-pane DTOs in project tree order.
pub fn ui_nodes(&self) -> Vec<UiNodeView> {
let primary_visual = self.primary_visual_product();
let product_preview =
|product: &UiProductRef| self.sync.as_ref()?.product_preview(product).cloned();
let asset_editor =
Expand All @@ -403,6 +426,7 @@ impl ProjectController {
&edits,
&extra_config,
&asset_editor,
primary_visual.as_ref(),
)
})
.collect()
Expand Down Expand Up @@ -1071,6 +1095,7 @@ impl ProjectController {
inventory: &ProjectInventorySummary,
) -> ProjectEditorView {
let summary = self.sync_summary().unwrap_or_default();
let primary_visual = self.primary_visual_product();
let product_preview =
|product: &UiProductRef| self.sync.as_ref()?.product_preview(product).cloned();
let asset_editor =
Expand All @@ -1087,6 +1112,7 @@ impl ProjectController {
&edits,
&extra_config,
&asset_editor,
primary_visual.as_ref(),
)
})
.collect::<Vec<_>>();
Expand Down Expand Up @@ -1668,6 +1694,10 @@ impl ProjectController {
for node in &self.root_nodes {
self.collect_subscribed_products(node, &mut product_refs);
}
// The primary visual streams whenever a project is open — the
// project's face is always live regardless of node focus (ADR
// 2026-07-16-primary-visual-product; M6 P3).
product_refs.extend(self.primary_visual_product());
product_refs.into_iter().collect()
}

Expand Down Expand Up @@ -4038,6 +4068,134 @@ mod tests {
);
}

fn primary_visual_graph(value: Option<lpc_model::LpValue>) -> lpc_wire::WireBindingGraph {
lpc_wire::WireBindingGraph {
revision: Revision::new(2),
bindings: Vec::new(),
channels: vec![lpc_wire::WireBusChannel {
name: lpc_model::PRIMARY_VISUAL_CHANNEL.to_string(),
kind: Some(lpc_model::Kind::Color),
providers: Vec::new(),
consumers: Vec::new(),
value: Some(lpc_wire::WireBusChannelValue {
revision: Revision::new(2),
value,
error: None,
}),
}],
}
}

fn project_with_graph(graph: lpc_wire::WireBindingGraph) -> ProjectController {
let mut project = ProjectController::new();
project.mark_ready("loaded-project", 7, ProjectInventorySummary::default());
project
.apply_project_view(&single_node_view(1, NodeRuntimeStatus::Ok))
.unwrap();
project
.sync_mut()
.unwrap()
.set_binding_graph_for_test(graph);
project
}

#[test]
fn primary_visual_product_reads_the_resolved_channel_value() {
// The engine already resolved visual.out by provider priority; the
// helper reads that answer (ADR 2026-07-16-primary-visual-product).
let product = lpc_model::ProductRef::visual(lpc_model::VisualProduct::new(
lpc_model::NodeId::new(5),
0,
));
let project = project_with_graph(primary_visual_graph(Some(lpc_model::LpValue::Product(
product,
))));
assert_eq!(
project.primary_visual_product(),
Some(UiProductRef::Visual {
node_id: 5,
output: 0
})
);
}

#[test]
fn primary_visual_product_empty_states_are_none() {
// No graph yet.
let mut project = ProjectController::new();
project.mark_ready("loaded-project", 7, ProjectInventorySummary::default());
assert_eq!(project.primary_visual_product(), None);

// Channel present but unresolved (no provider produced a value).
let project = project_with_graph(primary_visual_graph(None));
assert_eq!(project.primary_visual_product(), None);

// Channel resolved to a non-product value: defined empty, not a guess.
let project = project_with_graph(primary_visual_graph(Some(lpc_model::LpValue::F32(1.0))));
assert_eq!(project.primary_visual_product(), None);
}

#[test]
fn primary_visual_product_presents_live_without_focus() {
// The owning node is unfocused with Default intent, but the primary
// visual's preview presents as Tracking, not Paused/Untracked.
let mut view = single_node_view(1, NodeRuntimeStatus::Ok);
install_ui_projection_slots(&mut view, 1, Revision::new(4));
let mut project = ProjectController::new();
project.mark_ready("loaded-project", 7, ProjectInventorySummary::default());
project.apply_project_view(&view).unwrap();
let product = lpc_model::ProductRef::visual(lpc_model::VisualProduct::new(
lpc_model::NodeId::new(1),
0,
));
project
.sync_mut()
.unwrap()
.set_binding_graph_for_test(primary_visual_graph(Some(lpc_model::LpValue::Product(
product,
))));

let nodes = project.ui_nodes();
let products = node_sections(&nodes[0])
.iter()
.find_map(|section| match section {
UiNodeSection::ProducedProducts(products) => Some(products.clone()),
_ => None,
})
.expect("produced products section");
let primary = products
.iter()
.find(|product| {
product.product
== Some(UiProductRef::Visual {
node_id: 1,
output: 0,
})
})
.expect("primary product row");
assert_eq!(primary.tracking, UiProductTrackingState::Tracking);
}

#[test]
fn primary_visual_product_is_always_subscribed() {
// Nothing focused, no explicit intent: the project's face still
// streams (M6 P3 — always-live primary preview).
let product = lpc_model::ProductRef::visual(lpc_model::VisualProduct::new(
lpc_model::NodeId::new(1),
0,
));
let project = project_with_graph(primary_visual_graph(Some(lpc_model::LpValue::Product(
product,
))));
assert_eq!(
project.subscribed_products(),
vec![UiProductRef::Visual {
node_id: 1,
output: 0
}]
);
}

#[test]
fn default_wiring_offers_bind_not_retarget() {
let mut view = single_node_view(1, NodeRuntimeStatus::Ok);
Expand Down
Loading
Loading