mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(studio): timeline inline expansion + __clipTree runtime primitive
When a child element inside a sub-composition is selected, the timeline replaces the parent scene clip with the deepest-level siblings. Deselect or selecting outside collapses back. Expanded clips are fully editable — move, resize, delete, and split — addressed by their real DOM id with timeline time rebased onto the sub-comp they live in. Runtime: - New window.__clipTree API: a read-only hierarchical ClipNode tree (id/parentId/children + backing element) so Studio can derive parent/child relationships for inline expansion. Studio: - useExpandedTimelineElements derives the expanded view from selectedElementId + clipParentMap (pure useMemo, no useEffect). Each child rebases onto its immediate sub-comp host (start + sourceFile), so multi-level nesting targets the right file. - NLELayout routes expanded-clip edits through the same handlers top-level clips use, in local coordinates — edits save to the sub-comp source and reflect via reloadPreview (no separate DOM-patch path). This is the canonical update; there is no reactive observer. - findMatchingTimelineElementId resolves sub-comp children with no top-level element to `sourceFile#id`. - Razor tool enabled by default; studio_razor_split analytics event fired on single and split-all. - O(n²) isElementGsapTargeted extracted to gsapTargetCache.ts with a cached Set+WeakSet O(1) lookup.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
|
||||
import { useStudioShellContext } from "../../contexts/StudioContext";
|
||||
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
||||
@@ -111,6 +111,29 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
const cacheElementKey = element?.id ?? element?.selector ?? "";
|
||||
const cacheEntry = usePlayerStore((s) => s.keyframeCache.get(cacheElementKey));
|
||||
|
||||
const iframeRef = previewIframeRef ?? { current: null };
|
||||
const gsapAnimIdForMemo = element
|
||||
? (gsapAnimations?.find((a: { keyframes?: unknown }) => a.keyframes)?.id ??
|
||||
gsapAnimations?.[0]?.id ??
|
||||
null)
|
||||
: null;
|
||||
const gsapRuntimeValues = useMemo(
|
||||
() =>
|
||||
element
|
||||
? readGsapRuntimeValuesForPanel(gsapAnimIdForMemo, gsapAnimations, element, iframeRef)
|
||||
: null,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- iframeRef is stable; currentTime drives re-reads during playback
|
||||
[gsapAnimIdForMemo, gsapAnimations, element, currentTime],
|
||||
);
|
||||
const gsapBorderRadius = useMemo(
|
||||
() =>
|
||||
element
|
||||
? readGsapBorderRadiusForPanel(gsapRuntimeValues, gsapAnimations, element, iframeRef)
|
||||
: null,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[gsapRuntimeValues, gsapAnimations, element, currentTime],
|
||||
);
|
||||
|
||||
if (!element) {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-neutral-900">
|
||||
@@ -194,21 +217,6 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
return gsapAnimId ?? "";
|
||||
};
|
||||
|
||||
// Read ALL GSAP-interpolated values at the current seek time.
|
||||
const gsapRuntimeValues = readGsapRuntimeValuesForPanel(
|
||||
gsapAnimId,
|
||||
gsapAnimations,
|
||||
element,
|
||||
previewIframeRef ?? { current: null },
|
||||
);
|
||||
|
||||
const gsapBorderRadius = readGsapBorderRadiusForPanel(
|
||||
gsapRuntimeValues,
|
||||
gsapAnimations,
|
||||
element,
|
||||
previewIframeRef ?? { current: null },
|
||||
);
|
||||
|
||||
const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
|
||||
const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
|
||||
const displayW = gsapRuntimeValues?.width ?? resolvedWidth;
|
||||
|
||||
@@ -73,7 +73,7 @@ export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
env,
|
||||
["VITE_STUDIO_ENABLE_RAZOR_TOOL", "VITE_STUDIO_RAZOR_TOOL_ENABLED"],
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
// When disabled (the default), drag/resize/rotate commits always take the CSS
|
||||
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
||||
import { NLEPreview } from "./NLEPreview";
|
||||
import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
|
||||
import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
|
||||
import { useCompositionStack } from "./useCompositionStack";
|
||||
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
||||
import { trackStudioExpandedClipEdit } from "../../telemetry/events";
|
||||
import {
|
||||
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
||||
getTimelineToggleTitle,
|
||||
@@ -58,6 +61,7 @@ interface NLELayoutProps {
|
||||
blockName: string,
|
||||
position: { left: number; top: number },
|
||||
) => Promise<void> | void;
|
||||
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
onSelectTimelineElement?: (element: TimelineElement | null) => void;
|
||||
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
||||
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
||||
@@ -103,6 +107,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
onPreviewBlockDrop,
|
||||
onBlockedEditAttempt,
|
||||
onSelectTimelineElement,
|
||||
onCompIdToSrcChange,
|
||||
timelineVisible,
|
||||
@@ -175,6 +180,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
const handleDrillDown = useCallback(
|
||||
(element: TimelineElement) => {
|
||||
if (!element.compositionSrc) return;
|
||||
usePlayerStore.getState().setSelectedElementId(null);
|
||||
// Check compIdToSrc map first; then scan iframe DOM; then fall through to drillDown
|
||||
const compId = element.id;
|
||||
let resolvedPath = compIdToSrc.get(compId);
|
||||
@@ -202,6 +208,73 @@ export const NLELayout = memo(function NLELayout({
|
||||
[compIdToSrc, drillDown, iframeRef_],
|
||||
);
|
||||
|
||||
// Move/resize/split come from the timeline edit context, not props — the
|
||||
// wrappers below intercept expanded clips and must call the *real* handlers.
|
||||
// (Delete is a direct prop; it stays that way.)
|
||||
const { onMoveElement, onResizeElement, onSplitElement } = useTimelineEditContext();
|
||||
|
||||
// An expanded sub-comp child reaches the normal edit handlers in its own
|
||||
// local coordinates: addressed by its real DOM id, with timeline time rebased
|
||||
// onto the sub-comp it lives in. The handlers then save + reloadPreview exactly
|
||||
// as they do for top-level clips — no separate live-DOM path.
|
||||
const toLocalElement = useCallback(
|
||||
(element: TimelineElement, basis: number): TimelineElement => ({
|
||||
...element,
|
||||
id: element.domId ?? element.id,
|
||||
start: element.start - basis,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMoveElement = useCallback(
|
||||
(element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
||||
const basis = element.expandedParentStart;
|
||||
if (basis === undefined) return onMoveElement?.(element, updates);
|
||||
trackStudioExpandedClipEdit({ action: "move" });
|
||||
onMoveElement?.(toLocalElement(element, basis), {
|
||||
...updates,
|
||||
start: Math.max(0, updates.start - basis),
|
||||
});
|
||||
},
|
||||
[onMoveElement, toLocalElement],
|
||||
);
|
||||
|
||||
const handleResizeElement = useCallback(
|
||||
(
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => {
|
||||
const basis = element.expandedParentStart;
|
||||
if (basis === undefined) return onResizeElement?.(element, updates);
|
||||
trackStudioExpandedClipEdit({ action: "resize" });
|
||||
onResizeElement?.(toLocalElement(element, basis), {
|
||||
...updates,
|
||||
start: Math.max(0, updates.start - basis),
|
||||
});
|
||||
},
|
||||
[onResizeElement, toLocalElement],
|
||||
);
|
||||
|
||||
const handleDeleteElement = useCallback(
|
||||
(element: TimelineElement) => {
|
||||
const basis = element.expandedParentStart;
|
||||
if (basis === undefined) return onDeleteElement?.(element);
|
||||
trackStudioExpandedClipEdit({ action: "delete" });
|
||||
return onDeleteElement?.(toLocalElement(element, basis));
|
||||
},
|
||||
[onDeleteElement, toLocalElement],
|
||||
);
|
||||
|
||||
const handleSplitElement = useCallback(
|
||||
(element: TimelineElement, splitTime: number) => {
|
||||
const basis = element.expandedParentStart;
|
||||
if (basis === undefined) return onSplitElement?.(element, splitTime);
|
||||
trackStudioExpandedClipEdit({ action: "split" });
|
||||
return onSplitElement?.(toLocalElement(element, basis), Math.max(0, splitTime - basis));
|
||||
},
|
||||
[onSplitElement, toLocalElement],
|
||||
);
|
||||
|
||||
// Composition ID → file path map from raw index.html
|
||||
const compIdToSrcRef = useRef(compIdToSrc);
|
||||
compIdToSrcRef.current = compIdToSrc;
|
||||
@@ -356,6 +429,17 @@ export const NLELayout = memo(function NLELayout({
|
||||
<div
|
||||
className="flex-1 min-h-0 relative"
|
||||
data-preview-pan-surface="true"
|
||||
onPointerDown={(e) => {
|
||||
const el = iframeRef.current?.parentElement ?? iframeRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const inside =
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= rect.bottom;
|
||||
if (!inside) onSelectTimelineElement?.(null);
|
||||
}}
|
||||
onDragOver={handlePreviewDragOver}
|
||||
onDragLeave={handlePreviewDragLeave}
|
||||
onDrop={handlePreviewDrop}
|
||||
@@ -429,9 +513,13 @@ export const NLELayout = memo(function NLELayout({
|
||||
onDrillDown={handleDrillDown}
|
||||
renderClipContent={renderClipContent}
|
||||
onFileDrop={onFileDrop}
|
||||
onDeleteElement={onDeleteElement}
|
||||
onDeleteElement={handleDeleteElement}
|
||||
onAssetDrop={onAssetDrop}
|
||||
onBlockDrop={onBlockDrop}
|
||||
onMoveElement={handleMoveElement}
|
||||
onResizeElement={handleResizeElement}
|
||||
onBlockedEditAttempt={onBlockedEditAttempt}
|
||||
onSplitElement={handleSplitElement}
|
||||
onSelectElement={onSelectTimelineElement}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export function formatFieldsSuffix(rawFields: unknown): string {
|
||||
const fields = Array.isArray(rawFields)
|
||||
? rawFields.filter((f): f is string => typeof f === "string")
|
||||
: [];
|
||||
return fields.length > 0 ? ` (${fields.join(", ")})` : "";
|
||||
}
|
||||
|
||||
export async function readJsonResponseBody(res: Response): Promise<unknown> {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
@@ -55,14 +62,10 @@ function formatGsapMutationHttpErrorMessage(statusCode: number, body: unknown):
|
||||
export function formatGsapMutationRejectionToast(error: GsapMutationHttpError): string {
|
||||
const body = error.responseBody;
|
||||
if (isRecord(body)) {
|
||||
const fields = Array.isArray(body.fields)
|
||||
? body.fields.filter((field): field is string => typeof field === "string")
|
||||
: [];
|
||||
const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : "";
|
||||
return `Couldn't save animation: ${formatGsapMutationHttpErrorMessage(
|
||||
error.statusCode,
|
||||
body,
|
||||
)}${suffix}`;
|
||||
)}${formatFieldsSuffix(body.fields)}`;
|
||||
}
|
||||
return `Couldn't save animation: ${error.message}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
|
||||
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
|
||||
|
||||
let _gsapCachedTimelines: Record<string, TimelineLike> | undefined;
|
||||
let _gsapTargetIds: Set<string> | undefined;
|
||||
let _gsapTargetNodes: WeakSet<Element> | undefined;
|
||||
|
||||
function addTargetsFromTimeline(tl: TimelineLike, ids: Set<string>, nodes: WeakSet<Element>): void {
|
||||
const children = tl.getChildren?.(true);
|
||||
if (!children) return;
|
||||
for (const child of children) {
|
||||
const targets = child.targets?.();
|
||||
if (!targets) continue;
|
||||
for (const t of targets) {
|
||||
nodes.add(t);
|
||||
if (t.id) ids.add(t.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectGsapTargets(timelines: Record<string, TimelineLike>): {
|
||||
ids: Set<string>;
|
||||
nodes: WeakSet<Element>;
|
||||
} {
|
||||
const ids = new Set<string>();
|
||||
const nodes = new WeakSet<Element>();
|
||||
for (const tl of Object.values(timelines)) {
|
||||
if (!tl) continue;
|
||||
try {
|
||||
addTargetsFromTimeline(tl, ids, nodes);
|
||||
} catch {
|
||||
/* teardown race */
|
||||
}
|
||||
}
|
||||
return { ids, nodes };
|
||||
}
|
||||
|
||||
function readTimelines(iframe: HTMLIFrameElement | null): Record<string, TimelineLike> | undefined {
|
||||
if (!iframe?.contentWindow) return undefined;
|
||||
try {
|
||||
return (iframe.contentWindow as Window & { __timelines?: Record<string, TimelineLike> })
|
||||
.__timelines;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function isElementGsapTargeted(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
element: HTMLElement,
|
||||
): boolean {
|
||||
if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false;
|
||||
const timelines = readTimelines(iframe);
|
||||
if (!timelines) return false;
|
||||
|
||||
if (timelines !== _gsapCachedTimelines) {
|
||||
const cache = collectGsapTargets(timelines);
|
||||
_gsapTargetIds = cache.ids;
|
||||
_gsapTargetNodes = cache.nodes;
|
||||
_gsapCachedTimelines = timelines;
|
||||
}
|
||||
|
||||
return _gsapTargetNodes!.has(element) || !!(element.id && _gsapTargetIds!.has(element.id));
|
||||
}
|
||||
@@ -136,6 +136,7 @@ interface HotkeyCallbacks {
|
||||
onToggleRecording?: () => void;
|
||||
leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
|
||||
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
}
|
||||
|
||||
function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): boolean {
|
||||
@@ -205,6 +206,14 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
|
||||
void cb.handleTimelineElementSplit(el, currentTime);
|
||||
return;
|
||||
}
|
||||
// Expanded sub-comp children carry a qualified `sourceFile#id` selection
|
||||
// that isn't in the raw `elements` list, so the s-key can't resolve them.
|
||||
// Nudge toward the razor tool instead of failing silently.
|
||||
if (!el && selectedElementId.includes("#")) {
|
||||
event.preventDefault();
|
||||
cb.showToast("Use the razor tool (B) to split clips inside a sub-composition", "info");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,6 +385,7 @@ export function useAppHotkeys({
|
||||
onToggleRecording,
|
||||
leftSidebarRef,
|
||||
domEditSelectionRef,
|
||||
showToast,
|
||||
};
|
||||
|
||||
// ── Keydown dispatch ──
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
|
||||
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
||||
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
||||
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
||||
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
@@ -31,14 +32,7 @@ async function readErrorResponseBody(
|
||||
|
||||
function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
|
||||
if (!body?.error) return "Couldn't save edit";
|
||||
// Pre-existing clone of the GSAP save-error formatter (gsapScriptCommitHelpers);
|
||||
// surfaced here by this PR's adjacent edits, not introduced by it.
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const fields = Array.isArray(body.fields)
|
||||
? body.fields.filter((field): field is string => typeof field === "string")
|
||||
: [];
|
||||
const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : "";
|
||||
return `Couldn't save edit: ${body.error}${suffix}`;
|
||||
return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
|
||||
}
|
||||
|
||||
interface RecordEditInput {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
|
||||
import {
|
||||
applyStudioPathOffset,
|
||||
@@ -19,45 +18,11 @@ import {
|
||||
} from "../components/editor/manualEditsDomPatches";
|
||||
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
||||
import type { PatchOperation } from "../utils/sourcePatcher";
|
||||
import { isElementGsapTargeted } from "./gsapTargetCache";
|
||||
|
||||
export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
|
||||
"This element is GSAP-animated — dragging via CSS would corrupt keyframes";
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean {
|
||||
// When the GSAP drag intercept is disabled for debugging, treat every
|
||||
// element as un-targeted so commits take the plain CSS persist path.
|
||||
if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false;
|
||||
if (!iframe?.contentWindow) return false;
|
||||
let timelines: Record<string, TimelineLike> | undefined;
|
||||
try {
|
||||
timelines = (iframe.contentWindow as Window & { __timelines?: Record<string, TimelineLike> })
|
||||
.__timelines;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!timelines) return false;
|
||||
const id = element.id;
|
||||
for (const tl of Object.values(timelines)) {
|
||||
if (!tl?.getChildren) continue;
|
||||
try {
|
||||
for (const child of tl.getChildren(true)) {
|
||||
if (!child.targets) continue;
|
||||
for (const t of child.targets()) {
|
||||
if (t === element || (id && t.id === id)) return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
interface UseDomGeometryCommitsParams {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers";
|
||||
import { trackStudioRazorSplit } from "../telemetry/events";
|
||||
import {
|
||||
canSplitElement,
|
||||
buildPatchTarget,
|
||||
@@ -196,6 +197,7 @@ export function useRazorSplit({
|
||||
});
|
||||
|
||||
reloadPreview();
|
||||
trackStudioRazorSplit({ mode: "single", count: 1 });
|
||||
showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
|
||||
if (skippedSelectors?.length) {
|
||||
showToast(
|
||||
@@ -277,6 +279,7 @@ export function useRazorSplit({
|
||||
});
|
||||
|
||||
reloadPreview();
|
||||
trackStudioRazorSplit({ mode: "all", count: splitCount });
|
||||
showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to split clips";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
|
||||
import { isMusicTrack } from "../../utils/timelineInspector";
|
||||
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { EditPopover } from "./EditModal";
|
||||
import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme";
|
||||
@@ -28,7 +29,10 @@ import {
|
||||
shouldShowTimelineShortcutHint,
|
||||
} from "./timelineLayout";
|
||||
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
||||
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
||||
import {
|
||||
useResolvedTimelineEditCallbacks,
|
||||
type TimelineEditOverrides,
|
||||
} from "./useResolvedTimelineEditCallbacks";
|
||||
|
||||
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
||||
export {
|
||||
@@ -45,7 +49,7 @@ export {
|
||||
getDefaultDroppedTrack,
|
||||
} from "./timelineLayout";
|
||||
|
||||
interface TimelineProps extends TimelineDropCallbacks {
|
||||
interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
|
||||
onSeek?: (time: number) => void;
|
||||
onDrillDown?: (element: TimelineElement) => void;
|
||||
renderClipContent?: (
|
||||
@@ -67,6 +71,10 @@ export const Timeline = memo(function Timeline({
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
onDeleteElement: _onDeleteElement,
|
||||
onMoveElement: onMoveElementOverride,
|
||||
onResizeElement: onResizeElementOverride,
|
||||
onBlockedEditAttempt: onBlockedEditAttemptOverride,
|
||||
onSplitElement: onSplitElementOverride,
|
||||
onSelectElement,
|
||||
theme: themeOverrides,
|
||||
}: TimelineProps = {}) {
|
||||
@@ -80,14 +88,18 @@ export const Timeline = memo(function Timeline({
|
||||
onDeleteAllKeyframes,
|
||||
onChangeKeyframeEase,
|
||||
onMoveKeyframe,
|
||||
} = useTimelineEditContext();
|
||||
} = useResolvedTimelineEditCallbacks({
|
||||
onMoveElement: onMoveElementOverride,
|
||||
onResizeElement: onResizeElementOverride,
|
||||
onBlockedEditAttempt: onBlockedEditAttemptOverride,
|
||||
onSplitElement: onSplitElementOverride,
|
||||
});
|
||||
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
||||
useMusicBeatAnalysis();
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
const rawElements = usePlayerStore((s) => s.elements);
|
||||
const expandedElements = useExpandedTimelineElements();
|
||||
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
|
||||
const musicElement = usePlayerStore((s) => s.elements.find(isMusicTrack) ?? null);
|
||||
|
||||
// Merge user edits + remap beats from audio-file → composition coordinates.
|
||||
const beatEdits = usePlayerStore((s) => s.beatEdits);
|
||||
const adjustedBeatAnalysis = useMemo(
|
||||
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
|
||||
@@ -176,21 +188,21 @@ export const Timeline = memo(function Timeline({
|
||||
|
||||
const effectiveDuration = useMemo(() => {
|
||||
const safeDur = Number.isFinite(duration) ? duration : 0;
|
||||
if (elements.length === 0) return safeDur;
|
||||
const maxEnd = Math.max(...elements.map((el) => el.start + el.duration));
|
||||
if (rawElements.length === 0) return safeDur;
|
||||
const maxEnd = Math.max(...rawElements.map((el) => el.start + el.duration));
|
||||
const result = Math.max(safeDur, maxEnd);
|
||||
return Number.isFinite(result) ? result : safeDur;
|
||||
}, [elements, duration]);
|
||||
}, [rawElements, duration]);
|
||||
|
||||
const tracks = useMemo(() => {
|
||||
const map = new Map<number, typeof elements>();
|
||||
for (const el of elements) {
|
||||
const map = new Map<number, typeof expandedElements>();
|
||||
for (const el of expandedElements) {
|
||||
const list = map.get(el.track) ?? [];
|
||||
list.push(el);
|
||||
map.set(el.track, list);
|
||||
}
|
||||
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
||||
}, [elements]);
|
||||
}, [expandedElements]);
|
||||
|
||||
const trackStyles = useMemo(() => {
|
||||
const map = new Map<number, TrackVisualStyle>();
|
||||
@@ -247,8 +259,9 @@ export const Timeline = memo(function Timeline({
|
||||
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
|
||||
|
||||
const selectedElement = useMemo(
|
||||
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
||||
[elements, selectedElementId],
|
||||
() =>
|
||||
expandedElements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
||||
[expandedElements, selectedElementId],
|
||||
);
|
||||
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
|
||||
selectedElementRef.current = selectedElement;
|
||||
@@ -283,7 +296,7 @@ export const Timeline = memo(function Timeline({
|
||||
effectiveDuration,
|
||||
pps,
|
||||
timelineReady,
|
||||
elementsLength: elements.length,
|
||||
elementsLength: expandedElements.length,
|
||||
setZoomMode,
|
||||
setManualZoomPercent,
|
||||
onSeek,
|
||||
@@ -332,7 +345,7 @@ export const Timeline = memo(function Timeline({
|
||||
|
||||
useEffect(() => {
|
||||
syncShortcutHintVisibility();
|
||||
}, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
|
||||
}, [syncShortcutHintVisibility, timelineReady, expandedElements.length, totalH]);
|
||||
|
||||
const getPreviewElement = useCallback(
|
||||
(element: TimelineElement): TimelineElement => {
|
||||
@@ -362,7 +375,7 @@ export const Timeline = memo(function Timeline({
|
||||
onBlockDrop,
|
||||
});
|
||||
|
||||
if (!timelineReady || elements.length === 0) {
|
||||
if (!timelineReady || expandedElements.length === 0) {
|
||||
return (
|
||||
<TimelineEmptyState
|
||||
isDragOver={isDragOver}
|
||||
@@ -482,7 +495,7 @@ export const Timeline = memo(function Timeline({
|
||||
}
|
||||
}}
|
||||
onContextMenuKeyframe={(e, elId, pct) => {
|
||||
const el = elements.find((x) => (x.key ?? x.id) === elId);
|
||||
const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
|
||||
if (el) {
|
||||
setSelectedElementId(elId);
|
||||
onSelectElement?.(el);
|
||||
|
||||
@@ -102,7 +102,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{/* Left accent stripe */}
|
||||
{/* Left accent stripe — wider + brighter for expanded sub-comp children */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
@@ -110,9 +110,9 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 3,
|
||||
width: el.expandedParentStart !== undefined ? 4 : 3,
|
||||
background: trackStyle.accent,
|
||||
opacity: isSelected ? 0.7 : 0.3,
|
||||
opacity: el.expandedParentStart !== undefined ? 0.8 : isSelected ? 0.7 : 0.3,
|
||||
borderRadius: `${theme.clipRadius} 0 0 ${theme.clipRadius}`,
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
|
||||
// Props a parent (e.g. NLELayout) may pass to <Timeline> to intercept edits —
|
||||
// the rest of the callback bag still comes from TimelineEditContext.
|
||||
export type TimelineEditOverrides = Pick<
|
||||
TimelineEditCallbacks,
|
||||
"onMoveElement" | "onResizeElement" | "onBlockedEditAttempt" | "onSplitElement"
|
||||
>;
|
||||
|
||||
// Merge any prop overrides over the context callbacks. Used so NLELayout can
|
||||
// wrap move/resize/split (to rebase expanded sub-comp clips) while every other
|
||||
// callback falls through to the context unchanged.
|
||||
export function useResolvedTimelineEditCallbacks(
|
||||
overrides: TimelineEditOverrides,
|
||||
): TimelineEditCallbacks {
|
||||
const ctx = useTimelineEditContext();
|
||||
const { onMoveElement, onResizeElement, onBlockedEditAttempt, onSplitElement } = overrides;
|
||||
return useMemo(
|
||||
() => ({
|
||||
...ctx,
|
||||
onMoveElement: onMoveElement ?? ctx.onMoveElement,
|
||||
onResizeElement: onResizeElement ?? ctx.onResizeElement,
|
||||
onBlockedEditAttempt: onBlockedEditAttempt ?? ctx.onBlockedEditAttempt,
|
||||
onSplitElement: onSplitElement ?? ctx.onSplitElement,
|
||||
}),
|
||||
[ctx, onMoveElement, onResizeElement, onBlockedEditAttempt, onSplitElement],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildExpandedElements } from "./useExpandedTimelineElements";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
|
||||
const clip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
|
||||
id: "x",
|
||||
label: "x",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
track: 0,
|
||||
kind: "element",
|
||||
tagName: "div",
|
||||
compositionId: null,
|
||||
parentCompositionId: null,
|
||||
compositionSrc: null,
|
||||
assetUrl: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const el = (over: Partial<TimelineElement>): TimelineElement =>
|
||||
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as TimelineElement;
|
||||
|
||||
describe("buildExpandedElements", () => {
|
||||
it("rebases a 1-level child onto its sub-comp host (start + sourceFile)", () => {
|
||||
// host s3 at absolute 16 → stats-panel.html; children live in that file.
|
||||
const elements = [el({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" })];
|
||||
const manifest = [
|
||||
clip({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" }),
|
||||
clip({ id: "stat-1", start: 16.5, duration: 5 }),
|
||||
clip({ id: "stat-2", start: 16.9, duration: 5 }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["stat-1", "s3"],
|
||||
["stat-2", "s3"],
|
||||
]);
|
||||
|
||||
const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
|
||||
const child = out.find((e) => e.domId === "stat-1")!;
|
||||
expect(child.expandedParentStart).toBe(16);
|
||||
expect(child.sourceFile).toBe("stats.html");
|
||||
});
|
||||
|
||||
it("rebases a 2-level child onto its NESTED host, not the top-level scene", () => {
|
||||
// top host A@10 (a.html) embeds host B@12 (b.html); child C lives in b.html.
|
||||
// Edits must rebase onto B (12 / b.html), not A (10 / a.html).
|
||||
const elements = [el({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" })];
|
||||
const manifest = [
|
||||
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
||||
clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
|
||||
clip({ id: "C", start: 13, duration: 2 }),
|
||||
clip({ id: "C2", start: 14, duration: 1 }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["B", "A"],
|
||||
["C", "B"],
|
||||
["C2", "B"],
|
||||
]);
|
||||
|
||||
// Expanding C's siblings: topLevel A, immediate parent B.
|
||||
const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
|
||||
const child = out.find((e) => e.domId === "C")!;
|
||||
expect(child.expandedParentStart).toBe(12); // B's start, not A's 10
|
||||
expect(child.sourceFile).toBe("b.html"); // B's file, not a.html
|
||||
});
|
||||
|
||||
it("rebases a 3-level child onto its deepest host, not intermediate or top", () => {
|
||||
// A@10 (a.html) → B@12 (b.html) → C@13 (c.html); leaf D lives in c.html.
|
||||
// Edits must rebase onto C (13 / c.html), not B (12 / b.html) or A (10 / a.html).
|
||||
const elements = [el({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" })];
|
||||
const manifest = [
|
||||
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
||||
clip({ id: "B", start: 12, duration: 5, compositionSrc: "b.html" }),
|
||||
clip({ id: "C", start: 13, duration: 3, compositionSrc: "c.html" }),
|
||||
clip({ id: "D", start: 13.5, duration: 1 }),
|
||||
clip({ id: "D2", start: 14, duration: 1 }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["B", "A"],
|
||||
["C", "B"],
|
||||
["D", "C"],
|
||||
["D2", "C"],
|
||||
]);
|
||||
|
||||
// Expanding D's siblings: topLevel A, immediate parent C.
|
||||
const out = buildExpandedElements(elements, manifest, parentMap, "A", "C");
|
||||
const child = out.find((e) => e.domId === "D")!;
|
||||
expect(child.expandedParentStart).toBe(13); // C's start, not B's 12 or A's 10
|
||||
expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useMemo } from "react";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
|
||||
|
||||
function findTopLevelAncestor(id: string, parentMap: Map<string, string>): string | null {
|
||||
let current = parentMap.get(id);
|
||||
if (!current) return null;
|
||||
const visited = new Set<string>();
|
||||
visited.add(id);
|
||||
while (parentMap.has(current)) {
|
||||
if (visited.has(current)) return current;
|
||||
visited.add(current);
|
||||
current = parentMap.get(current)!;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function extractDomId(key: string): string {
|
||||
const hashIdx = key.lastIndexOf("#");
|
||||
return hashIdx >= 0 ? key.slice(hashIdx + 1) : key;
|
||||
}
|
||||
|
||||
function resolveRawId(
|
||||
selectedId: string | null,
|
||||
manifest: ClipManifestClip[],
|
||||
parentMap: Map<string, string>,
|
||||
): string | null {
|
||||
if (!selectedId) return null;
|
||||
const rawId = extractDomId(selectedId);
|
||||
if (parentMap.has(rawId)) return rawId;
|
||||
if (parentMap.has(selectedId)) return selectedId;
|
||||
const clip = manifest.find((c) => c.label === selectedId || c.label === rawId);
|
||||
if (clip?.id && parentMap.has(clip.id)) return clip.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
function filterToTopLevel(
|
||||
elements: TimelineElement[],
|
||||
parentMap: Map<string, string>,
|
||||
): TimelineElement[] {
|
||||
if (parentMap.size === 0) return elements;
|
||||
return elements.filter((el) => !parentMap.has(el.domId ?? el.id));
|
||||
}
|
||||
|
||||
function clampChildToParent(
|
||||
child: ClipManifestClip,
|
||||
parentStart: number,
|
||||
parentEnd: number,
|
||||
): { start: number; duration: number } | null {
|
||||
const childEnd = child.start + child.duration;
|
||||
if (child.start >= parentEnd || childEnd <= parentStart) return null;
|
||||
const clampedStart = Math.max(child.start, parentStart);
|
||||
const clampedDuration = Math.min(childEnd, parentEnd) - clampedStart;
|
||||
return clampedDuration > 0 ? { start: clampedStart, duration: clampedDuration } : null;
|
||||
}
|
||||
|
||||
interface DisplayBounds {
|
||||
start: number;
|
||||
end: number;
|
||||
track: number;
|
||||
}
|
||||
|
||||
// `display` bounds come from the top-level scene clip (where the expanded row is
|
||||
// drawn). `editBasis` comes from the child's immediate sub-comp host: its absolute
|
||||
// start anchors local-time edits and its compositionSrc is the file edits write to.
|
||||
// They differ only for sub-comp-inside-sub-comp nesting.
|
||||
function buildChildElements(
|
||||
siblings: ClipManifestClip[],
|
||||
display: DisplayBounds,
|
||||
editBasis: { start: number; sourceFile: string | undefined },
|
||||
): TimelineElement[] {
|
||||
const result: TimelineElement[] = [];
|
||||
for (const child of siblings) {
|
||||
const clamped = clampChildToParent(child, display.start, display.end);
|
||||
if (!clamped) continue;
|
||||
const base = createTimelineElementFromManifestClip({
|
||||
clip: child,
|
||||
fallbackIndex: result.length,
|
||||
});
|
||||
result.push({
|
||||
...base,
|
||||
start: clamped.start,
|
||||
duration: clamped.duration,
|
||||
track: display.track + result.length,
|
||||
expandedParentStart: editBasis.start,
|
||||
domId: child.id ?? undefined,
|
||||
selector: child.id ? `#${child.id}` : undefined,
|
||||
sourceFile: editBasis.sourceFile,
|
||||
timingSource: "authored" as const,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Exported for tests.
|
||||
export function buildExpandedElements(
|
||||
elements: TimelineElement[],
|
||||
manifest: ClipManifestClip[],
|
||||
parentMap: Map<string, string>,
|
||||
topLevelId: string,
|
||||
siblingParentId: string,
|
||||
): TimelineElement[] {
|
||||
const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId);
|
||||
if (!topLevelElement) return filterToTopLevel(elements, parentMap);
|
||||
|
||||
const siblings = manifest.filter((c) => c.id != null && parentMap.get(c.id) === siblingParentId);
|
||||
if (siblings.length === 0) return filterToTopLevel(elements, parentMap);
|
||||
|
||||
// The sub-comp host the children actually live in: top-level host for 1-level
|
||||
// nesting, a nested host for deeper nesting. Its start/file anchor edits.
|
||||
const parentHost = manifest.find((c) => c.id === siblingParentId);
|
||||
const editBasis = {
|
||||
start: parentHost?.start ?? topLevelElement.start,
|
||||
sourceFile: parentHost?.compositionSrc ?? topLevelElement.compositionSrc ?? undefined,
|
||||
};
|
||||
|
||||
const parentKey = topLevelElement.key ?? topLevelElement.id;
|
||||
const expanded = buildChildElements(
|
||||
siblings,
|
||||
{
|
||||
start: topLevelElement.start,
|
||||
end: topLevelElement.start + topLevelElement.duration,
|
||||
track: topLevelElement.track,
|
||||
},
|
||||
editBasis,
|
||||
);
|
||||
if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
|
||||
|
||||
return elements
|
||||
.filter((el) => (el.key ?? el.id) === parentKey || !parentMap.has(el.domId ?? el.id))
|
||||
.flatMap((el) => ((el.key ?? el.id) === parentKey ? expanded : [el]));
|
||||
}
|
||||
|
||||
export function useExpandedTimelineElements(): TimelineElement[] {
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
const clipManifest = usePlayerStore((s) => s.clipManifest);
|
||||
const clipParentMap = usePlayerStore((s) => s.clipParentMap);
|
||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!clipManifest || clipManifest.length === 0 || clipParentMap.size === 0) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
const rawId = resolveRawId(selectedElementId, clipManifest, clipParentMap);
|
||||
if (!rawId) return filterToTopLevel(elements, clipParentMap);
|
||||
|
||||
const immediateParent = clipParentMap.get(rawId)!;
|
||||
const topLevel = findTopLevelAncestor(rawId, clipParentMap) ?? immediateParent;
|
||||
return buildExpandedElements(elements, clipManifest, clipParentMap, topLevel, immediateParent);
|
||||
}, [elements, clipManifest, clipParentMap, selectedElementId]);
|
||||
}
|
||||
@@ -66,6 +66,8 @@ export function useTimelineSyncCallbacks({
|
||||
return;
|
||||
}
|
||||
|
||||
usePlayerStore.getState().setClipManifest(data.clips);
|
||||
|
||||
// Show root-level clips: no parentCompositionId, OR parent is a "phantom wrapper"
|
||||
const clipCompositionIds = new Set(data.clips.map((c) => c.compositionId).filter(Boolean));
|
||||
const filtered = data.clips.filter(
|
||||
@@ -77,6 +79,26 @@ export function useTimelineSyncCallbacks({
|
||||
} catch {
|
||||
iframeDoc = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const iframeWin = iframeRef.current?.contentWindow as
|
||||
| (Window & { __clipTree?: import("@hyperframes/core/runtime/clipTree").ClipTree })
|
||||
| null;
|
||||
const clipTree = iframeWin?.__clipTree;
|
||||
if (clipTree) {
|
||||
const parentMap = new Map<string, string>();
|
||||
const walk = (nodes: typeof clipTree.roots) => {
|
||||
for (const node of nodes) {
|
||||
if (node.id && node.parentId) parentMap.set(node.id, node.parentId);
|
||||
if (node.children.length > 0) walk(node.children);
|
||||
}
|
||||
};
|
||||
walk(clipTree.roots);
|
||||
usePlayerStore.getState().setClipParentMap(parentMap);
|
||||
}
|
||||
} catch {
|
||||
// cross-origin or __clipTree not available — parentMap stays empty
|
||||
}
|
||||
const usedHostEls = new Set<Element>();
|
||||
const els: TimelineElement[] = filtered.map((clip, index) => {
|
||||
const hostEl = iframeDoc
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
||||
import type { BeatEditState } from "../../utils/beatEditing";
|
||||
import type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||
|
||||
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
|
||||
@@ -50,6 +51,13 @@ export interface TimelineElement {
|
||||
timelineLocked?: boolean;
|
||||
/** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
|
||||
timelineRole?: string;
|
||||
/**
|
||||
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
|
||||
* child: the absolute master-timeline start of the sub-comp host the child
|
||||
* lives in. Presence marks the element as expanded; edits subtract it to get
|
||||
* the child's local (sourceFile-relative) time. Works at any nesting depth.
|
||||
*/
|
||||
expandedParentStart?: number;
|
||||
}
|
||||
|
||||
export type ZoomMode = "fit" | "manual";
|
||||
@@ -138,21 +146,22 @@ interface PlayerState {
|
||||
/** Undo/redo stacks for beat edits (in-memory, session-only). */
|
||||
beatUndo: BeatHistoryEntry[];
|
||||
beatRedo: BeatHistoryEntry[];
|
||||
/** Apply a beat edit and record it for undo. */
|
||||
commitBeatEdits: (next: BeatEditState | null, label: string) => void;
|
||||
/** Undo/redo the most recent beat edit; returns its label or null if none. */
|
||||
undoBeatEdits: () => string | null;
|
||||
redoBeatEdits: () => string | null;
|
||||
/** Clear beat edit history (e.g. when the music track changes). */
|
||||
resetBeatHistory: () => void;
|
||||
/** Callback that persists current beats to disk; registered by the analysis hook. */
|
||||
beatPersist: (() => void) | null;
|
||||
setBeatPersist: (fn: (() => void) | null) => void;
|
||||
|
||||
clipManifest: ClipManifestClip[] | null;
|
||||
setClipManifest: (clips: ClipManifestClip[] | null) => void;
|
||||
clipParentMap: Map<string, string>;
|
||||
setClipParentMap: (map: Map<string, string>) => void;
|
||||
}
|
||||
|
||||
interface BeatHistoryEntry {
|
||||
restore: BeatEditState | null; // state to restore when this entry is applied
|
||||
at: number; // original edit timestamp (for global undo ordering)
|
||||
restore: BeatEditState | null;
|
||||
at: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@@ -271,6 +280,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
return entry.label;
|
||||
},
|
||||
|
||||
clipManifest: null,
|
||||
setClipManifest: (clips) => set({ clipManifest: clips }),
|
||||
clipParentMap: new Map(),
|
||||
setClipParentMap: (map) => set({ clipParentMap: map }),
|
||||
|
||||
setIsPlaying: (playing) => {
|
||||
if (get().isPlaying === playing) return;
|
||||
set({ isPlaying: playing });
|
||||
@@ -338,12 +352,12 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
selectedKeyframes: new Set(),
|
||||
selectedElementIds: new Set(),
|
||||
keyframeCache: new Map(),
|
||||
// Beat state is project-specific — clear it so a project switch can't
|
||||
// apply the previous project's beats/undo/persist to the new one.
|
||||
beatAnalysis: null,
|
||||
beatEdits: null,
|
||||
beatUndo: [],
|
||||
beatRedo: [],
|
||||
beatPersist: null,
|
||||
clipManifest: null,
|
||||
clipParentMap: new Map(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -7,7 +7,12 @@ vi.mock("./client", () => ({
|
||||
trackEvent: (...args: unknown[]) => trackEvent(...args),
|
||||
}));
|
||||
|
||||
const { trackStudioSessionStart, trackStudioRenderStart } = await import("./events");
|
||||
const {
|
||||
trackStudioSessionStart,
|
||||
trackStudioRenderStart,
|
||||
trackStudioRazorSplit,
|
||||
trackStudioExpandedClipEdit,
|
||||
} = await import("./events");
|
||||
|
||||
describe("studio telemetry events", () => {
|
||||
beforeEach(() => {
|
||||
@@ -54,4 +59,14 @@ describe("studio telemetry events", () => {
|
||||
composition: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("trackStudioRazorSplit emits 'studio_razor_split' with mode and count", () => {
|
||||
trackStudioRazorSplit({ mode: "all", count: 3 });
|
||||
expect(trackEvent).toHaveBeenCalledWith("studio_razor_split", { mode: "all", count: 3 });
|
||||
});
|
||||
|
||||
it("trackStudioExpandedClipEdit emits 'studio_expanded_clip_edit' with action", () => {
|
||||
trackStudioExpandedClipEdit({ action: "resize" });
|
||||
expect(trackEvent).toHaveBeenCalledWith("studio_expanded_clip_edit", { action: "resize" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,21 @@ function getBrowserDoctorSummary(): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function trackStudioRazorSplit(props: { mode: "single" | "all"; count: number }): void {
|
||||
trackEvent("studio_razor_split", {
|
||||
mode: props.mode,
|
||||
count: props.count,
|
||||
});
|
||||
}
|
||||
|
||||
// Adoption signal for the inline timeline-expansion surface: edits applied to a
|
||||
// sub-composition child clip while its parent scene is expanded.
|
||||
export function trackStudioExpandedClipEdit(props: {
|
||||
action: "move" | "resize" | "delete" | "split";
|
||||
}): void {
|
||||
trackEvent("studio_expanded_clip_edit", { action: props.action });
|
||||
}
|
||||
|
||||
export function trackStudioFeedback(props: { rating: number; comment?: string }): void {
|
||||
trackEvent("survey sent", {
|
||||
$survey_id: "studio_experience",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveTimelineSelectionSeekTime } from "./studioHelpers";
|
||||
import { findMatchingTimelineElementId, resolveTimelineSelectionSeekTime } from "./studioHelpers";
|
||||
|
||||
describe("resolveTimelineSelectionSeekTime", () => {
|
||||
it("keeps the current time when it is already inside the clip range", () => {
|
||||
@@ -18,3 +18,27 @@ describe("resolveTimelineSelectionSeekTime", () => {
|
||||
expect(resolveTimelineSelectionSeekTime(Number.NaN, { start: 2, duration: 5 })).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMatchingTimelineElementId", () => {
|
||||
const el = (over: Record<string, unknown>) =>
|
||||
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
|
||||
|
||||
it("matches a top-level element by domId + sourceFile", () => {
|
||||
const els = [el({ id: "s1", domId: "s1", sourceFile: "index.html" })];
|
||||
expect(findMatchingTimelineElementId({ id: "s1", sourceFile: "index.html" }, els)).toBe("s1");
|
||||
});
|
||||
|
||||
it("returns a qualified id for a sub-comp child with no matching timeline element", () => {
|
||||
const els = [el({ id: "s3", domId: "s3", sourceFile: "index.html" })];
|
||||
expect(
|
||||
findMatchingTimelineElementId(
|
||||
{ id: "stat-3", sourceFile: "compositions/stats-panel.html" },
|
||||
els,
|
||||
),
|
||||
).toBe("compositions/stats-panel.html#stat-3");
|
||||
});
|
||||
|
||||
it("returns null for an unmatched element in index.html", () => {
|
||||
expect(findMatchingTimelineElementId({ id: "ghost", sourceFile: "index.html" }, [])).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,38 +116,64 @@ export function getHistoryShortcutLabel(action: "undo" | "redo"): string {
|
||||
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
|
||||
}
|
||||
|
||||
type ElementMatchSelection = Pick<
|
||||
DomEditSelection,
|
||||
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
|
||||
>;
|
||||
|
||||
function matchesByDomId(
|
||||
selection: ElementMatchSelection,
|
||||
element: TimelineElement,
|
||||
selectionSourceFile: string,
|
||||
): boolean {
|
||||
if (!selection.id) return false;
|
||||
return (
|
||||
element.domId === selection.id && (element.sourceFile || "index.html") === selectionSourceFile
|
||||
);
|
||||
}
|
||||
|
||||
function matchesByCompositionHost(
|
||||
selection: ElementMatchSelection,
|
||||
element: TimelineElement,
|
||||
): boolean {
|
||||
if (!selection.isCompositionHost || !selection.compositionSrc) return false;
|
||||
return element.compositionSrc === selection.compositionSrc;
|
||||
}
|
||||
|
||||
function matchesBySelector(selection: ElementMatchSelection, element: TimelineElement): boolean {
|
||||
if (!selection.selector) return false;
|
||||
return (
|
||||
element.selector === selection.selector &&
|
||||
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
|
||||
(element.sourceFile ?? "index.html") === selection.sourceFile
|
||||
);
|
||||
}
|
||||
|
||||
function elementMatchesSelection(
|
||||
selection: ElementMatchSelection,
|
||||
element: TimelineElement,
|
||||
selectionSourceFile: string,
|
||||
): boolean {
|
||||
return (
|
||||
matchesByDomId(selection, element, selectionSourceFile) ||
|
||||
matchesByCompositionHost(selection, element) ||
|
||||
matchesBySelector(selection, element)
|
||||
);
|
||||
}
|
||||
|
||||
export function findMatchingTimelineElementId(
|
||||
selection: Pick<
|
||||
DomEditSelection,
|
||||
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
|
||||
>,
|
||||
selection: ElementMatchSelection,
|
||||
elements: TimelineElement[],
|
||||
): string | null {
|
||||
const selectionSourceFile = selection.sourceFile || "index.html";
|
||||
for (const element of elements) {
|
||||
const elementSourceFile = element.sourceFile || "index.html";
|
||||
if (
|
||||
selection.id &&
|
||||
element.domId === selection.id &&
|
||||
elementSourceFile === selectionSourceFile
|
||||
) {
|
||||
return element.key ?? element.id;
|
||||
}
|
||||
if (
|
||||
selection.isCompositionHost &&
|
||||
selection.compositionSrc &&
|
||||
element.compositionSrc === selection.compositionSrc
|
||||
) {
|
||||
return element.key ?? element.id;
|
||||
}
|
||||
if (
|
||||
selection.selector &&
|
||||
element.selector === selection.selector &&
|
||||
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
|
||||
(element.sourceFile ?? "index.html") === selection.sourceFile
|
||||
) {
|
||||
return element.key ?? element.id;
|
||||
}
|
||||
const match = elements.find((el) => elementMatchesSelection(selection, el, selectionSourceFile));
|
||||
if (match) return match.key ?? match.id;
|
||||
|
||||
// Child inside a sub-composition: return a qualified ID so the expansion
|
||||
// hook can resolve the child via clipParentMap even though no timeline
|
||||
// element exists for it yet (the expansion creates it on the fly).
|
||||
if (selection.id && selectionSourceFile !== "index.html") {
|
||||
return `${selectionSourceFile}#${selection.id}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user