Files
hyperframes/packages/studio/src/player/components/timelineZMirror.ts
T
Miguel Ángel 2b65b4efce fix(studio): harden composition timeline reliability (#2615)
* fix(studio): preserve composition playback continuity

* feat(studio): drag compositions into the timeline

* fix(studio): collapse expanded composition move aliases

* fix(studio): make timeline cuts atomic

* fix(studio): group inspector gesture history

* test(studio): cover masked text selection

* fix(studio): harden composition timeline reliability

* fix(studio): satisfy CI source gates

* fix(studio): harden composition mutation requests
2026-07-17 14:15:30 -04:00

394 lines
17 KiB
TypeScript

import type { TimelineElement } from "../store/playerStore";
import { classifyZone } from "./timelineZones";
import { isLaneFree, timeRangesOverlap } from "./timelineCollision";
import { authoredTrackForLane, sameSourceFile } from "./timelineAuthoredTrack";
import { samePaintScope } from "./timelineStackingSync";
/**
* Mirror a canvas z-order action (Bring to Front / Bring Forward / Send Backward /
* Send to Back) into a timeline LANE move — the pure resolver, no UI wiring.
*
* ── The model ────────────────────────────────────────────────────────────────
* Track order is the DEFAULT paint order; authored z is the ADVANCED override.
* Render truth stays z — the renderer never reads track index — and the studio
* maintains z ↔ track consistency at EDIT time: a deliberate vertical lane move
* syncs z (timelineStackingSync), and a z-order menu action calls THIS resolver
* to compute the accompanying lane move. When the user authors z that diverges
* from track order, the mirror never fights the authored override — it only
* keeps the default in step.
*
* ── Locked rules (agreed design — do not re-litigate here) ───────────────────
* - The mirror computes a lane move to ACCOMPANY a z action on a timeline clip;
* it never replaces the z patch.
* - ONE-ELEMENT STEP (bring-forward / send-backward): the z action stepped past
* exactly ONE element — the reference neighbor — so the lane move must too.
* Target = the free lane (whole-span, file-agnostic occupancy, same zone)
* closest to the reference, searched STRICTLY BETWEEN the reference's lane
* and the next temporally-overlapping same-file visual element's lane in the
* direction (exclusive bound). No free lane in that open interval (the common
* back-to-back case) → CREATE one at the boundary immediately beyond the
* reference neighbor (commitTrackInsert semantics) — never scan past the
* second element to a farther free lane, which would overshoot the paint
* order. With no second overlapping element
* beyond the reference, the bound is the zone edge: closest free lane beyond
* the neighbor, else insert immediately beyond it.
* - bring-to-front / send-to-back move past the WHOLE overlap set: closest free
* lane beyond the extreme overlap in the direction, else insert adjacent to
* the extreme.
* - Direction: bring-forward/front = toward LOWER display lanes (up = above);
* send-backward/back = toward HIGHER lanes, but only within the visual zone —
* the audio zone is untouched and never crossed (a bottom-of-zone insert lands
* AT the visual/audio boundary, i.e. still a visual lane).
* - Reference scope: same source file AND same stacking context (see
* samePaintScope — a file can contain several stacking contexts, and leaf z
* is only comparable within one). Lane FREENESS stays file-agnostic (any
* clip in the zone occupies its lane for everyone).
* - Non-clip decorations (no timeline presence) are out of scope — callers keep
* z-only behavior for them. Audio elements never mirror (z on audio is
* meaningless); the resolver returns null.
*
* ── OPEN product question ────────────────────────────────────────────────────
* send-to-back / bring-to-front scope: below/above EVERYTHING visual, or only
* the clips that temporally overlap the moved clip? The default implemented
* here is TEMPORAL-OVERLAP scope (the extreme is computed over same-file clips
* that overlap the moved clip in time), pending M/Bin sign-off. A clip with no
* temporal overlaps in the direction is "already at the extreme" → null.
*
* Deterministic: a pure function of its inputs — no Date, no randomness, no DOM.
*/
export type ZMirrorAction = "bring-to-front" | "bring-forward" | "send-backward" | "send-to-back";
export interface ZMirrorInput {
action: ZMirrorAction;
/** The clip acted on — store/display space (post-normalizeToZones lanes),
* carrying `authoredTrack` when the display lane diverges from the file. */
element: TimelineElement;
/** The expanded display element set (same set the drag commit reasons on). */
elements: TimelineElement[];
/** Timeline key of the neighbor the z action stepped over (forward/backward),
* when known — see resolveCrossedNeighbor in canvasContextMenuZOrder. */
crossedKey?: string | null;
}
export type ZMirrorLaneMove =
| {
/** Land on an existing display lane. */
kind: "move";
/** Display lane to move to (store space). */
displayTrack: number;
/** Authored-space value to write (authoredTrackForLane translation). */
persistTrack: number;
}
| {
/** Create a new lane: boundary row compatible with commitTrackInsert's
* insertRow (index into the ascending display trackOrder; 0 = above the
* top lane, length = below the bottom). */
kind: "insert";
insertRow: number;
}
| null;
const keyOf = (el: TimelineElement): string => el.key ?? el.id;
/**
* Lane candidates for an EXPANDED sub-comp child: only its own siblings' lanes
* (same file). An expanded child's display row is synthetic host-space — landing
* it on an arbitrary host lane has no same-file occupant to translate the
* authored track from, and a track INSERT would renumber host space from a
* child origin. Ordinary top-level clips return null (no restriction).
*/
function expandedChildAllowedLanes(
element: TimelineElement,
elements: TimelineElement[],
): ReadonlySet<number> | null {
if (element.expandedParentStart == null) return null;
const selfKey = keyOf(element);
return new Set(
elements
.filter((el) => keyOf(el) !== selfKey && sameSourceFile(el, element))
.map((el) => el.track),
);
}
/** Ascending unique display lanes of `elements` — identical to how Timeline.tsx
* builds `trackOrder`, so `insertRow` indexes the same boundary space. Exported
* so the mirror wiring can hand commitZMirrorLaneMove the matching trackOrder. */
export function displayTrackOrder(elements: TimelineElement[]): number[] {
return [...new Set(elements.map((el) => el.track))].sort((a, b) => a - b);
}
/**
* Resolve the timeline lane move that mirrors a z-order action on `element`.
* Returns null when no timeline mirror applies: audio / zero-length clips, no
* reference neighbor in the action's direction (the menu action was likely
* disabled or a no-op), or the clip is already laned where the action puts it.
*/
export function resolveZMirrorLaneMove(input: ZMirrorInput): ZMirrorLaneMove {
const { action, element, elements } = input;
if (classifyZone(element) === "audio") return null;
if (!(element.duration > 0)) return null;
const selfKey = keyOf(element);
const start = element.start;
const end = element.start + element.duration;
const up = action === "bring-forward" || action === "bring-to-front";
// Same paint scope (source file + stacking context), visual, temporally overlapping.
const overlapSet = elements.filter(
(el) =>
keyOf(el) !== selfKey &&
classifyZone(el) === "visual" &&
samePaintScope(el, element) &&
timeRangesOverlap(start, end, el.start, el.start + el.duration),
);
const referenceLane = resolveReferenceLane(input, overlapSet, up);
if (referenceLane == null) return null;
const order = displayTrackOrder(elements);
const visualLanes = displayTrackOrder(elements.filter((el) => classifyZone(el) === "visual"));
const refIdx = visualLanes.indexOf(referenceLane);
if (refIdx === -1) return null; // reference is not a visual lane — no mirror
const boundLane = stepBoundLane(action, overlapSet, referenceLane, up);
const allowedLanes = expandedChildAllowedLanes(element, elements);
const lane = closestFreeLane({
elements,
visualLanes,
refIdx,
up,
boundLane,
start,
end,
selfKey,
allowedLanes,
});
if (lane != null) {
// The closest free lane is the clip's OWN lane (possible only when z and
// track had diverged): the clip already sits where the action puts it.
if (lane === element.track) return null;
return {
kind: "move",
displayTrack: lane,
persistTrack: authoredTrackForLane(lane, elements, element),
};
}
// Expanded children never INSERT: a new lane is a host-lane-space renumber,
// meaningless in the child's own file (buildTrackInsertEdits refuses too).
if (allowedLanes) return null;
// No free lane before the bound (or the zone edge) → create one adjacent to
// the reference: the boundary between its lane and the next in direction.
return { kind: "insert", insertRow: order.indexOf(referenceLane) + (up ? 0 : 1) };
}
export interface ZRepositionInput {
/** The clip the Layers-panel drag moved — store/display space. */
element: TimelineElement;
/** The expanded display element set (same set the drag commit reasons on). */
elements: TimelineElement[];
/**
* Timeline keys of the reordered sibling set in DESIRED render order,
* bottom→top, the moved element's own key included at its new slot. Siblings
* with no timeline presence carry null — they are skipped when resolving the
* nearest clip neighbors.
*/
desiredOrderKeys: ReadonlyArray<string | null>;
}
/**
* Mirror an ARBITRARY z repositioning (a Layers-panel drag, which can jump
* several siblings in one drop) into a timeline lane move — the "equal jump"
* generalization of {@link resolveZMirrorLaneMove}'s one-step rule: the clip
* lands between its NEW paint neighbors' lanes.
*
* The reference lanes are the nearest siblings in the desired render order
* that are visual, same-file timeline clips: `above` = the first such sibling
* that now paints above the moved clip, `below` = the first that paints below.
* Target lane = the free lane (whole-span, same zone) strictly between the
* above-neighbor's lane and the below-neighbor's lane, closest to the above
* neighbor; when only one neighbor exists the zone edge bounds the search on
* the open side. No free lane in the interval → INSERT a new lane immediately
* beyond the above neighbor (below it), or immediately above the below
* neighbor when dropped on top — commitTrackInsert semantics, exactly like the
* menu mirror's insert fallback.
*
* Null when no mirror applies: audio / zero-length clips, no clip neighbor in
* the set (a z-only decoration shuffle), or the clip already sits where the
* drop puts it.
*/
// fallow-ignore-next-line complexity
export function resolveRepositionLaneMove(input: ZRepositionInput): ZMirrorLaneMove {
const { element, elements, desiredOrderKeys } = input;
if (classifyZone(element) === "audio") return null;
if (!(element.duration > 0)) return null;
const selfKey = keyOf(element);
const selfIdx = desiredOrderKeys.indexOf(selfKey);
if (selfIdx === -1) return null;
const clipLaneForKey = (key: string | null): number | null => {
if (key == null) return null;
const el = elements.find((candidate) => keyOf(candidate) === key);
return el &&
classifyZone(el) === "visual" &&
el.duration > 0 &&
samePaintScope(el, element) &&
keyOf(el) !== selfKey
? el.track
: null;
};
// Nearest clip neighbor painting ABOVE (later in bottom→top order) / BELOW.
let aboveLane: number | null = null;
for (let i = selfIdx + 1; i < desiredOrderKeys.length && aboveLane == null; i++) {
aboveLane = clipLaneForKey(desiredOrderKeys[i]);
}
let belowLane: number | null = null;
for (let i = selfIdx - 1; i >= 0 && belowLane == null; i--) {
belowLane = clipLaneForKey(desiredOrderKeys[i]);
}
if (aboveLane == null && belowLane == null) return null;
const order = displayTrackOrder(elements);
const visualLanes = displayTrackOrder(elements.filter((el) => classifyZone(el) === "visual"));
const allowedLanes = expandedChildAllowedLanes(element, elements);
const args = {
elements,
visualLanes,
start: element.start,
end: element.start + element.duration,
selfKey,
allowedLanes,
};
let lane: number | null;
let insertRow: number;
if (aboveLane != null) {
// Paints above = LOWER display lane: search DOWNWARD from the above
// neighbor (closest lane under it first), bounded by the below neighbor
// (exclusive) when one exists, else by the zone edge.
const refIdx = visualLanes.indexOf(aboveLane);
if (refIdx === -1) return null;
lane = closestFreeLane({ ...args, refIdx, up: false, boundLane: belowLane });
insertRow = order.indexOf(aboveLane) + 1;
} else {
// Dropped above everything that remains: search UPWARD from the below
// neighbor toward the zone top.
const refIdx = visualLanes.indexOf(belowLane!);
if (refIdx === -1) return null;
lane = closestFreeLane({ ...args, refIdx, up: true, boundLane: null });
insertRow = order.indexOf(belowLane!);
}
if (lane != null) {
if (lane === element.track) return null;
return {
kind: "move",
displayTrack: lane,
persistTrack: authoredTrackForLane(lane, elements, element),
};
}
// Expanded children never INSERT (host-lane-space renumber from a child
// origin) — see expandedChildAllowedLanes.
if (allowedLanes) return null;
return { kind: "insert", insertRow };
}
/**
* ONE-ELEMENT-STEP bound (bring-forward / send-backward only): the lane of the
* NEXT temporally-overlapping same-file visual element strictly beyond the
* reference in the direction — the free-lane search may not reach it
* (exclusive). Front/back have no bound (they step past the whole overlap
* set), and neither does a step with no second overlapping element beyond the
* reference (the zone edge bounds instead).
*/
function stepBoundLane(
action: ZMirrorAction,
overlapSet: TimelineElement[],
referenceLane: number,
up: boolean,
): number | null {
if (action !== "bring-forward" && action !== "send-backward") return null;
const beyond = overlapSet
.map((el) => el.track)
.filter((lane) => (up ? lane < referenceLane : lane > referenceLane));
if (beyond.length === 0) return null;
return (up ? Math.max : Math.min)(...beyond);
}
/**
* Closest free lane strictly beyond the reference, lane-by-lane in direction,
* whole-span freeness, same zone (visual lanes only — never into audio),
* stopping at the exclusive bound when one applies. Null → no free lane in
* the open interval.
*/
function closestFreeLane(args: {
elements: TimelineElement[];
visualLanes: number[];
refIdx: number;
up: boolean;
boundLane: number | null;
start: number;
end: number;
selfKey: string;
/** When set, only these lanes are candidates (expanded-child scoping). */
allowedLanes?: ReadonlySet<number> | null;
}): number | null {
const { elements, visualLanes, refIdx, up, boundLane, start, end, selfKey, allowedLanes } = args;
const step = up ? -1 : 1;
for (let i = refIdx + step; i >= 0 && i < visualLanes.length; i += step) {
const lane = visualLanes[i];
if (pastSearchBound(lane, boundLane, up)) break;
if (allowedLanes && !allowedLanes.has(lane)) continue;
if (isLaneFree(elements, lane, start, end, selfKey)) return lane;
}
return null;
}
/** The exclusive one-element-step bound: stop at (never on/past) `boundLane`. */
function pastSearchBound(lane: number, boundLane: number | null, up: boolean): boolean {
if (boundLane == null) return false;
return up ? lane <= boundLane : lane >= boundLane;
}
/**
* The lane the search starts from (the "reference neighbor"):
* - forward/backward: the crossed neighbor when provided and valid (a visual
* clip in the set); otherwise the closest temporally-overlapping same-file
* clip in the direction. None → null (the menu was probably disabled).
* - front/back: the extreme of the temporal-overlap set — topmost (lowest lane)
* for front, bottommost (highest lane) for back — restricted to overlaps
* strictly beyond the clip's own lane. None → already at the extreme → null.
*/
function resolveReferenceLane(
input: ZMirrorInput,
overlapSet: TimelineElement[],
up: boolean,
): number | null {
const stepAction = input.action === "bring-forward" || input.action === "send-backward";
if (stepAction) {
const crossedLane = crossedNeighborLane(input);
// Unknown / absent / non-visual crossed key → the temporal neighbor below.
if (crossedLane != null) return crossedLane;
}
// Overlapping same-file lanes strictly beyond the moved clip's lane, in direction.
const lanes = overlapSet
.map((el) => el.track)
.filter((lane) => (up ? lane < input.element.track : lane > input.element.track));
if (lanes.length === 0) return null;
// Step actions want the CLOSEST lane in direction (max when up, min when
// down); front/back want the EXTREME of the set (min when up, max when down).
return (stepAction === up ? Math.max : Math.min)(...lanes);
}
/** The crossed neighbor's display lane, when the key names a visual clip in the set. */
function crossedNeighborLane({ elements, crossedKey }: ZMirrorInput): number | null {
if (crossedKey == null) return null;
const crossed = elements.find((el) => keyOf(el) === crossedKey);
return crossed && classifyZone(crossed) === "visual" ? crossed.track : null;
}