feat(view): mint managed lanes to separate cross-mode content per stance (D2 W3-A)

Pure planLaneMinting decides which tracks hold >1 mode's content and which managed
lane each item lands on; view.cpp applies it (I_FREEMODE/I_NUMFIXEDLANES/P_LANENAME/
I_FIXEDLANE) under one undo block, driven off the auto-tag detection tick. Manual lanes
and their items are never touched. Load-time reconcile rebuilds ownership from durable
lane names before reapplying active-mode visibility.
This commit is contained in:
2026-07-23 20:08:38 -04:00
parent fed70c0a80
commit b182146f9a
11 changed files with 673 additions and 4 deletions
+107
View File
@@ -519,6 +519,113 @@ std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackG
const std::vector<NewItem>& newItems,
const std::string& activeMode);
// -- Lane minting decision (Phase D2 / Wave 3) -------------------------------
//
// D1 parks a whole track when it holds content of only ONE mode. The moment a track
// would carry content of MORE THAN ONE mode, whole-track parking can no longer keep
// the stances separate (the track is visible in every mode its content belongs to),
// so the projection drops to the ITEM level: the track becomes a fixed-lane track,
// each involved mode gets its own MANAGED lane, and each item is assigned to its
// mode's lane. A toggle then shows+plays only the active mode's lane.
//
// This is the pure DECISION behind that transition — REAPER-free and unit-tested.
// The shell reads each track's items and their live mode+lane disposition, calls this,
// and applies the resulting REAPER writes (I_FREEMODE / I_NUMFIXEDLANES / P_LANENAME /
// I_FIXEDLANE) plus the ownership-index writes. The DECISION never lives in the shell.
//
// THE MANAGED-LANES-ONLY INVARIANT is upheld here at the source: an item the shell
// reports as already on a MANUAL lane is EXEMPT — it is never counted toward the
// multi-mode test, never reassigned, and its lane is never minted-over. The plan only
// ever names lanes with the managed prefix (laneNameForMode) and only ever moves
// managed-eligible items. A track the user already lane-splits for their own comping
// is handled by minting ADDITIONAL managed lanes alongside the user's manual lanes;
// the manual lanes and the items on them are untouched (they are reported exempt).
// One item the shell reports for the minting decision: its GUID, the mode its
// membership resolves to (untagged ⇒ Arrange, resolved by the shell via
// leafBelongsToMode / the active-mode default), and whether it currently sits on a
// MANUAL lane (⇒ exempt: never counted, never reassigned).
struct LaneItem {
std::string guid;
std::string modeId; // the mode this item's content belongs to
bool onManualLane = false; // true ⇒ EXEMPT (user's hand-managed lane)
};
// One track the shell reports: its GUID plus the items on it. The shell builds this by
// enumerating the track's media items and resolving each item's mode from membership.
struct LaneTrack {
std::string trackGuid;
std::vector<LaneItem> items;
};
// One item→lane assignment the shell must apply (I_FIXEDLANE = the lane the durable
// key `laneKey` currently occupies; the shell resolves key→ordinal exactly as the
// C_LANEPLAYS apply path does). Only managed-eligible items appear here.
struct LaneAssign {
std::string itemGuid;
std::string trackGuid;
std::string laneKey; // durable managed-lane key (laneNameForMode(modeId))
bool operator==(const LaneAssign& o) const {
return itemGuid == o.itemGuid && trackGuid == o.trackGuid && laneKey == o.laneKey;
}
};
// One managed lane the shell must mint on a track: its durable key (== the name to
// stamp via P_LANENAME) and the mode that owns it (recorded in the ownership index).
struct LaneMint {
std::string trackGuid;
std::string laneKey; // == laneNameForMode(modeId); the P_LANENAME to stamp
std::string modeId; // the owning mode (ownership-index managed-for-mode write)
bool operator==(const LaneMint& o) const {
return trackGuid == o.trackGuid && laneKey == o.laneKey && modeId == o.modeId;
}
};
// The complete lane-minting plan for the tracks the shell reported. Empty (all three
// vectors) when NO track needs splitting — a single-mode-only project produces an empty
// plan and the shell does nothing (D1 behavior unchanged). The shell wraps the whole
// application in ONE Undo block because it is a visible structural mutation.
struct LaneMintPlan {
// Tracks to switch into fixed-lane mode, each with the number of managed lanes to
// ensure (I_FREEMODE=2, I_NUMFIXEDLANES >= laneCount). Only tracks that need a
// split appear; a track already carrying the tool's managed lanes for exactly the
// involved modes still appears (idempotent — the shell's ensure is a no-op then).
struct TrackSplit {
std::string trackGuid;
int laneCount = 0; // number of managed lanes this track needs
};
std::vector<TrackSplit> splits;
std::vector<LaneMint> mints; // managed lanes to mint (name + ownership write)
std::vector<LaneAssign> assigns; // item→managed-lane assignments
bool empty() const {
return splits.empty() && mints.empty() && assigns.empty();
}
};
// The pure lane-minting decision. For each reported track:
// * Ignore items on manual lanes entirely (exempt — the managed-only invariant).
// * Collect the DISTINCT modes the remaining (managed-eligible) items belong to.
// * If that set has < 2 modes, the track stays whole-track-parked (D1) — NO split,
// NO mint, NO assignment. This is the single-mode-track rule.
// * If it has >= 2 modes, the track transitions to lane-split: emit one TrackSplit
// (laneCount == number of involved modes), one LaneMint per involved mode (durable
// key laneNameForMode(mode), owned by that mode), and one LaneAssign per managed-
// eligible item onto its mode's lane — INCLUDING the pre-existing items, so a
// single-mode track that just gained a second mode retroactively lanes ALL its
// items, not only the newly-added one.
//
// Items with an empty GUID or empty modeId are skipped (defensive; a real item always
// resolves to a mode). The function mutates nothing — it returns a plan the shell
// applies. Idempotency: re-reporting an already-split track yields the same mints and
// assignments; the shell's ensure/assign writes are no-ops when the state already
// matches, so re-running the detection path does not thrash the project or the undo
// history (the shell only opens an Undo block when the plan is non-empty AND some
// write actually changes state — see the shell).
LaneMintPlan planLaneMinting(const std::vector<LaneTrack>& tracks);
// The next mode id in the registry's ordinal order, cycling past `currentModeId`
// and wrapping to the first mode after the last (Arrange -> Design -> Arrange with
// the two seed modes; the same cycle scales to N modes with no call-site change).