Skip to content

fix(studio): keep saved strip positions when presets and scenes change - #434

Merged
hyperb1iss merged 4 commits into
mainfrom
nova/studio-layout-persist
Oct 19, 2026
Merged

hyperb1iss merged 4 commits into
mainfrom
nova/studio-layout-persist

Conversation

@hyperb1iss

Copy link
Copy Markdown
Owner

A user on Reddit reported that LED strip positions saved in Studio reset whenever they changed presets. Studio's Save writes the new placement only into the active scene's copy of the primary zone (PUT /api/v1/scene/zones/{zone}/layout), and the global active layout never gets it. Applying an effect or a preset rebuilds the primary zone from that global layout whenever the scene has no custom LED zones, which is the default setup for a new rig. The strip snaps back. Layout publications (hotplug, auto-layout, POST /layouts/{id}/apply) and a daemon restart reset it the same way. Every release since Studio shipped has this bug. With this PR, a saved placement lands in the shared layout and survives all of those paths.

What this changes

A Save carries its placements into the shared layout. When a Save targets a primary zone that follows the active layout (no display target, and no custom LED zones in its scene), the daemon copies the placements that Save changed into the active layout. It diffs the zone before and after the Save and takes only the placement fields that moved (position, size, rotation, scale, orientation), merged by output id. The changes go into the stored catalog entry in layouts.json and into the published layout separately, wherever each already holds that output. An output that neither layout has, such as a member assigned in Studio, is added to both. Nothing is ever removed, output order and every other layout field stay as they were, and auto-layout exclusions are never touched. The store is written first, then the layout publishes under the layout guard, and a failed publish rolls the store back. If either step is refused, the zone write is undone and the Save returns the refusal, so a Save never lands halfway.

Scene switches re-align a following primary zone. Activating, deactivating, or deleting the active scene, and starting into the configured scene, now re-align a following primary zone to the active layout. A named scene therefore never shows stale positions after a switch. Snapshot-locked scenes keep what they captured. A scene with its own layout_id is re-aligned again when that layout publishes.

Studio takes its revision from the response. The Save can now commit twice: the zone write plus the layout publication. Studio reads the scene revision from the response's ETag instead of assuming one bump, and falls back to one bump only when the header is missing.

Why it is shaped this way

The first version of this fix mirrored the whole primary zone into the active layout, and independent verification failed it. A named scene keeps the primary zone it was created with, so its zone can be a stale snapshot. Mirroring that snapshot had three bad effects:

  • It dropped outputs the layout had gained since, and auto-excluded their devices.
  • It reverted placements saved from another scene.
  • It overwrote a stored layout edit that had not been applied yet.

Copying only what the Save changed, by output id, keeps the Save from asserting anything about outputs it never touched. Re-aligning on activation removes the staleness that made the first version dangerous.

Verification

  • Added or updated tests
  • Added or updated docs (README, AGENTS.md, relevant spec, or guide)
  • just verify passes locally (Rust fmt + lint + test)
  • just deny passes (required for dependency or license changes)
  • just ui-test and just ui-build pass (required for crates/hypercolor-ui/)
  • just sdk-lint, just sdk-check, and just sdk-build pass (required for sdk/)
  • just python-verify passes (required for python/)
  • just compat-check passes (required for data/drivers/vendors/*.toml)
  • just docs-build passes (required for docs or README changes)
  • cd docs && zola check passes (required for docs link/content changes)
  • Packaging scripts were syntax-checked (required for scripts/ or packaging/)
  • just e2e-build passes with the normal Servo stack (required for daemon/UI/effect integration changes)
  • just e2e-build-cpu passes when validating the CPU smoke fallback
  • just e2e passes against the Servo stack (required for end-to-end behavior changes; starts daemon/browser)
  • Tested on real hardware, simulator, or e2e harness (describe below)

studio_layout_persistence_tests has 15 tests:

Group What it covers
Original report A saved placement survives a preset apply, an effect switch, a re-apply of the active layout, a hotplug-style republish, and a simulated restart. The store and published layout keep their identity fields.
Verification failures A stale named scene with a device only the active layout has, including the exclusions check. A Save in one scene keeping another scene's saved placement. A stored but unapplied layout edit.
Re-align and failure paths Activating a named scene, and returning to the Default scene. A rejected publication leaves the Save undone. A scene with its own layout takes it on activation. Members assigned in Studio join the layout.
Control A scene with a custom LED zone keeps its primary layout scene-local.

On origin/main, the original report's tests fail with the strip back at its old position. On the first version of the fix, the five verification-failure and re-align cases fail. All 15 pass now.

Gate results:

Command Result
Daemon test targets touching scenes, layouts, effects, zones, and persistence (28 targets, --test-threads=1) 592 passed
cargo test -p hypercolor-daemon --lib filtered to scene and layout 143 passed
cargo test -p hypercolor-core --test zone_lifecycle_tests 18 passed
cargo clippy -p hypercolor-daemon -p hypercolor-core --all-targets -- -D warnings clean
cargo fmt --all -- --check clean
hypercolor-ui: cargo check --lib and the api::client, api::zones, and layout_builder tests clean, 19 passed

An independent verifier failed the first version and passed this one, and its three reproduction probes pass against this head. startup_tests, daemon_integration_tests, and discovery_tests were not run locally, because they start real device discovery; CI runs them. The restart case is simulated from the persisted layouts.json and runtime session rather than a full DaemonState::start. The UI change has not been exercised in a browser.

Notes for reviewers

Start with mirror_primary_zone_save_under_guard in domain/layout/workflows.rs, then set_zone_layout and undo_zone_layout_save in domain/scene_tree.rs. The lock order matches activate_scene. The layout guard is taken before the scene mutation and released before connectivity convergence.

The verifier left four low-severity notes, all from static traces:

  • A removed output can come back. An effect apply can race a layout apply that removes an output, which puts the output back in the zone. That race predates this PR, but now the next Save of any member pushes the output back into both layouts, while its device stays on the exclusion list.
  • Activation overwrites a deliberately different primary layout. A live named scene whose primary layout was authored differently through PUT /scenes/{id} now loses that layout on activation. That matches the "follows the active layout" model; snapshot scenes are skipped.
  • The ETag can be one revision behind. That happens when auto-layout repair commits during the post-Save convergence. The next guarded write then gets the existing "reloaded, try again" 412.
  • A double failure leaves the store ahead. If both the publish and the store rollback fail, the stored entry keeps the new placement while the zone is undone. The error names both failures.

Not changed here: a Save that only reorders outputs is not mirrored. An active layout with no stored entry, such as a preview, is published but not persisted.

🤖 Generated with Claude Code

hyperb1iss and others added 4 commits October 19, 2026 09:39
Without a custom LED zone, effect applies and layout publications
rewrite the primary zone's layout from the active spatial layout, so
the daemon needs to know when a placement saved on that zone belongs
in the layout instead. The predicate wraps the existing custom LED
zone check so the scene manager and its callers share one rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A strip moved and saved in Studio snapped back on the next effect or
preset change. The save only rewrote the scene's copy of the primary
zone, but while a scene has no custom LED zone that copy follows the
active layout: effect applies, layout publications (hotplug, auto
layout, layout apply, scene activation) and the startup restore all
rebuild it from that layout, which never learned about the move.

Saving such a zone now also writes its outputs into the active layout.
The layout keeps its id, name, description, canvas size, sampling
defaults and version, and takes the zone's outputs in the zone's
order. The store entry is persisted and the layout republished through
the guarded activation path, so the spatial engine, the layout store
and the zone agree. The layout update guard is held from the zone read
through the republication and released before connectivity
convergence, which takes the guard itself. The workflow runs as one
spawned task so a dropped request cannot split the two writes, and the
Save answers with the post-publication revision when no other commit
landed in between. Scenes with a custom LED zone keep a scene-local
primary layout, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Mirroring the whole primary zone into the active layout was wrong
whenever the zone was a stale snapshot. A named scene keeps the zone
it was created with, and activation never re-aligned it, so a Save
there dropped outputs the layout gained since (and auto-excluded their
devices), reverted positions saved from another scene, and replaced a
stored layout edit that had not been applied yet.

A Save now copies only the placement fields of outputs whose placement
it changed, by output id, onto the stored entry and onto the published
layout separately, wherever each holds that output. Outputs neither
layout knows (members assigned in Studio) are added to both, as the
startup restore does. Nothing is removed, order and every other field
stay put, exclusions are untouched, and an unapplied stored edit stays
stored and unapplied. If the store write or the publication is refused,
the zone is put back and the refusal is returned, so a Save is never
left half applied; a failed rollback is named in the error.

Scene activation, deactivation, deleting the active scene, and the
configured startup scene now re-align a following primary zone to the
active layout, so a scene switch never shows stale positions. Snapshot
scenes keep what they captured, and a scene with its own layout is
re-aligned again when that layout publishes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Studio assumed a zone layout Save moves the scene revision by one. A
Save on a primary zone that follows the active layout also publishes
that layout, which is a second commit, so the next guarded write before
the scene refresh landed was refused as stale. The Save now records the
revision the daemon reported in ETag, falling back to the old guess only
when the header is missing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 19, 2026

Copy link
Copy Markdown

Warning

Review limit reached

  • Run on-demand review

This review includes 11 billable files and costs up to $2.75.

Or wait 23 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 2b60b578-c72f-42ef-bcdc-a4bfa2412e95
📥 Commits

Reviewing files that changed from the base of the PR and between 2943fb9 and 93395d6.

📒 Files selected for processing: 11
  • crates/hypercolor-core/src/scene/mod.rs
  • crates/hypercolor-core/tests/zone_lifecycle_tests.rs
  • crates/hypercolor-daemon/src/domain/layout/workflows.rs
  • crates/hypercolor-daemon/src/domain/scene.rs
  • crates/hypercolor-daemon/src/domain/scene_activation.rs
  • crates/hypercolor-daemon/src/domain/scene_tree.rs
  • crates/hypercolor-daemon/src/startup/lifecycle.rs
  • crates/hypercolor-daemon/tests/studio_layout_persistence_tests.rs
  • crates/hypercolor-ui/src/api/client.rs
  • crates/hypercolor-ui/src/api/zones.rs
  • crates/hypercolor-ui/src/components/layout_builder.rs
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@hyperb1iss
hyperb1iss merged commit b6d376f into main Oct 19, 2026
43 checks passed
@hyperb1iss
hyperb1iss deleted the nova/studio-layout-persist branch October 19, 2026 18:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant