mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
Review 1 (restore commit): - asset reveal now clears any open preview overlay (stuck-overlay repro: preview on A, click already-added B — A stayed open over the reveal) - duration readout rolls back on failed persist: captureDurationRollback snapshots store + live root data-duration before the optimistic sync and restores both in every move/resize/delete/group catch (golden's previousDuration pattern) - asset preview opened during running playback dismisses immediately (the RAF loop bypasses the store, so the subscription alone never fired) - persistTimelineBatchEdit resolves the target (findTagByTarget) before treating identical output as a no-op — a mistargeted member now throws like the single-element path instead of being silently dropped - a post-mutation history-fold failure no longer suppresses the preview sync: fold errors are surfaced separately and the rewritten script still syncs (previously the preview kept stale GSAP positions with no recovery) - timelineRevealScroll guards degenerate viewports (windowSize <= 0) - CodeQL: encodeURIComponent(projectId) at all timelineTimingSync fetches Review 2 (single-source-of-truth pass): - createTimelineElementFromManifestClip — the one manifest->element boundary — now carries authoredTrack and stackingContextId; expanded sub-comp children preserve both (authoredTrack in their OWN file's space) - authoredTrackForLane scopes occupants to the dragged clip's sourceFile (a foreign file's authored values are a different coordinate space); nearest-same-file-lane offset fallback - optimistic store updates mirror the persisted track into authoredTrack (and roll it back on failure), so consecutive drags before a reload resolve from fresh data - spill sub-lanes: documented decision — dropping onto a spill lane is a legitimate same-track join (occupants share the authored track by construction); false 'never a lane-move target' docstring rewritten - single-element fallback persists vertical-only moves (early return now requires neither start nor track changed; live DOM patch includes data-track-index) - canonical contextKey helper for stacking-context normalization - new pipeline test crosses the REAL factory boundary (sparse authored tracks -> factory -> expansion -> normalize -> drag commit -> persisted attribute), no injected fields
146 lines
6.2 KiB
TypeScript
146 lines
6.2 KiB
TypeScript
import type { TimelineElement } from "../store/playerStore";
|
|
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
|
|
/**
|
|
* Free-form vertical zones, top → bottom: visual, audio. There is no "main track"
|
|
* — canvas layering is CSS z-index (the renderer ignores track index), so the
|
|
* timeline's only job is to keep visual clips grouped above audio clips.
|
|
*/
|
|
export type TrackZone = "visual" | "audio";
|
|
|
|
/** Which zone a clip belongs to: audio elements sink to the bottom, everything
|
|
* else (video / image / text / sub-comp) is a visual lane on top. */
|
|
export function classifyZone(el: TimelineElement): TrackZone {
|
|
return isAudioTimelineElement(el) ? "audio" : "visual";
|
|
}
|
|
|
|
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
|
|
const EPS = 1e-6;
|
|
|
|
/** Two clips overlap when their half-open [start, end) intervals intersect. */
|
|
function overlaps(a: TimelineElement, b: TimelineElement): boolean {
|
|
return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
|
|
}
|
|
|
|
/** Deterministic order on the stable clip id (never the mutated lane/track). */
|
|
function byStableId(a: TimelineElement, b: TimelineElement): number {
|
|
const ka = keyOf(a);
|
|
const kb = keyOf(b);
|
|
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
}
|
|
|
|
/**
|
|
* Pack ONE authored track's clips onto sub-lanes so no two time-overlapping clips
|
|
* share a lane. Clips are ordered by their STABLE id (a function of the input, not
|
|
* of the lane being computed — the historical oscillation bug tie-broke on the
|
|
* mutated track) and placed first-fit, so sequential (non-overlapping) clips
|
|
* collapse onto a single lane and only genuine time overlaps spill onto adjacent
|
|
* sub-lanes. Writes each clip's absolute display lane into `laneOf`; returns the
|
|
* number of lanes used (≥ 1 when non-empty).
|
|
*
|
|
* The editor enforces no per-track time overlap, so the spill only fires on legacy
|
|
* files. It is DISPLAY-ONLY — a drag commit persists just the dragged clip, never
|
|
* this re-lane — so it never rewrites the source.
|
|
*
|
|
* Spill sub-lanes ARE legal drop targets (Timeline's trackOrder lists every
|
|
* display lane). Because every occupant of a sub-lane shares the base lane's
|
|
* authored track by construction, dropping a clip onto one persists that shared
|
|
* authored track — a legitimate same-track join. On the next normalize the
|
|
* joined track re-packs (stable-id first-fit), so the clip may display on a
|
|
* DIFFERENT sub-lane than it was dropped on; the packing is deterministic, and
|
|
* the persisted source value is correct either way.
|
|
*/
|
|
function packTrackLanes(
|
|
clips: TimelineElement[],
|
|
base: number,
|
|
laneOf: Map<string, number>,
|
|
): number {
|
|
const ordered = [...clips].sort(byStableId);
|
|
const lanes: TimelineElement[][] = [];
|
|
for (const el of ordered) {
|
|
let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o, el)));
|
|
if (sub === -1) {
|
|
sub = lanes.length;
|
|
lanes.push([]);
|
|
}
|
|
lanes[sub].push(el);
|
|
laneOf.set(keyOf(el), base + sub);
|
|
}
|
|
return Math.max(1, lanes.length);
|
|
}
|
|
|
|
/**
|
|
* Pack a whole zone's clips onto contiguous display lanes, CapCut-stable: lanes
|
|
* follow the authored `data-track-index` (ASCENDING; ties by stable id) — NEVER a
|
|
* z-rank. Each distinct authored track owns its own lane (in ascending order);
|
|
* sequential same-track clips share it; time-overlapping same-track clips spill to
|
|
* adjacent sub-lanes (packTrackLanes). Returns the number of lanes used.
|
|
*
|
|
* This REPLACES the old global-z-rank interval pack. That pack ordered visual
|
|
* lanes by z-index and interval-packed overlaps, so editing one clip's z (or the
|
|
* whole-set re-pack a lane drag triggered) silently re-laned OTHER clips. The
|
|
* product decision is the opposite: a clip's lane is its track, period — z is
|
|
* canvas paint order only, and lane assignment must ignore it.
|
|
*/
|
|
function packZoneLanes(
|
|
clips: TimelineElement[],
|
|
base: number,
|
|
laneOf: Map<string, number>,
|
|
): number {
|
|
const byTrack = new Map<number, TimelineElement[]>();
|
|
for (const el of clips) {
|
|
const list = byTrack.get(el.track);
|
|
if (list) list.push(el);
|
|
else byTrack.set(el.track, [el]);
|
|
}
|
|
let used = 0;
|
|
for (const track of [...byTrack.keys()].sort((a, b) => a - b)) {
|
|
used += packTrackLanes(byTrack.get(track)!, base + used, laneOf);
|
|
}
|
|
return used;
|
|
}
|
|
|
|
/**
|
|
* Assign display lanes for the timeline: visual lanes on top, audio lanes below.
|
|
*
|
|
* Both zones are packed the SAME way — by authored track-index, ascending (see
|
|
* packZoneLanes) — so the timeline's vertical order follows each clip's track and
|
|
* nothing else. z-index does not participate in lane assignment (it is canvas
|
|
* paint order only; the lane ↔ z stacking sync in timelineStackingSync runs the
|
|
* other direction, only on a deliberate vertical edit). Time-overlapping same-track
|
|
* clips still split onto separate sub-lanes (legacy files only — the editor forbids
|
|
* per-track overlap), and that split is display-only, never persisted.
|
|
*
|
|
* Pure — returns a new array; unchanged clips keep their identity. Display-only
|
|
* (runs on discovery); it does not rewrite the source. Idempotent (running it on
|
|
* its own output is a fixed point): the lanes it emits are contiguous integers in
|
|
* ascending track order, and re-running groups by those same integers unchanged.
|
|
*/
|
|
export function normalizeToZones(elements: TimelineElement[]): TimelineElement[] {
|
|
if (elements.length === 0) return elements;
|
|
|
|
const laneOf = new Map<string, number>();
|
|
const visual: TimelineElement[] = [];
|
|
const audio: TimelineElement[] = [];
|
|
for (const el of elements) {
|
|
(classifyZone(el) === "audio" ? audio : visual).push(el);
|
|
}
|
|
|
|
let nextLane = 0;
|
|
nextLane += packZoneLanes(visual, nextLane, laneOf);
|
|
packZoneLanes(audio, nextLane, laneOf);
|
|
|
|
let changed = false;
|
|
const remapped = elements.map((el) => {
|
|
const lane = laneOf.get(keyOf(el));
|
|
if (lane == null || lane === el.track) return el;
|
|
changed = true;
|
|
// Record the source-file track the first time a clip is remapped so lane
|
|
// edits can persist in AUTHORED space (see TimelineElement.authoredTrack).
|
|
// Re-normalizing already-remapped elements must keep the original value.
|
|
return { ...el, track: lane, authoredTrack: el.authoredTrack ?? el.track };
|
|
});
|
|
return changed ? remapped : elements;
|
|
}
|