mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-11 19:09:20 +00:00
feat(studio): glue API coexistence layer for the NLE swap
What: extends 21 glue files so the OLD timeline/canvas engine and the NEW NLE components type-check side by side: playerStore (multi-select setters, zoom pin, snap toggle, non-reactive scale scratch), drag-state types gain optional NLE fields, timelineLayout/timelineAssetDrop/timelineEditingHelpers/ timelineEditing/timelineElementHelpers/studioHelpers/assetHelpers gain the NLE exports, DomEditOverlay + gestures + AssetContextMenu + Timeline props gain optional callbacks/params, contexts gain *Optional hooks, and TimelineEditCallbacks.onMoveElements becomes a bivariant method accepting both engines' change shapes. patchDocumentRootDuration's test rides along. Why: this is the keystone that dissolves the old "welded glue" problem — every symbol the NLE components need is ADDED next to what the old engine still uses, so the engine components and the swaps can land as separate reviewable PRs. How: 15 authored intermediate files (main content + additive symbols; no behavior changes — new fields optional, new callbacks unused until wired) plus 6 files whose final content is already purely additive. New exports without consumers yet carry TEMP(studio-dnd) ignoreExports entries, removed by the app-shell swap. Test plan: tsc --noEmit in studio + studio-server (verifies BOTH engines compile); bunx vitest run (full suite green incl. the 6 new patchDocumentRootDuration tests); fallow audit clean.
This commit is contained in:
+149
@@ -103,6 +103,104 @@
|
||||
"packages/cli/src/cloud/_gen/**",
|
||||
],
|
||||
"ignoreExports": [
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/player/lib/timelineElementHelpers.ts",
|
||||
"exports": ["furthestClipEndFromDocument"],
|
||||
},
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/hooks/timelineEditingHelpers.ts",
|
||||
"exports": ["patchIframeRootDuration"],
|
||||
},
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/components/sidebar/AssetContextMenu.tsx",
|
||||
"exports": ["DeleteConfirm"],
|
||||
},
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/components/editor/domEditOverlayGeometry.ts",
|
||||
"exports": ["overlayCornersCentroid", "elementCornerOverlayPoints", "orientedOverlayRect"],
|
||||
},
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/contexts/DomEditContext.tsx",
|
||||
"exports": ["useDomEditActionsContextOptional"],
|
||||
},
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/contexts/StudioContext.tsx",
|
||||
"exports": ["useStudioShellContextOptional"],
|
||||
},
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/components/editor/domEditOverlayGestures.ts",
|
||||
"exports": ["ResizeHandle"],
|
||||
},
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/components/sidebar/assetHelpers.ts",
|
||||
"exports": ["truncateMiddle", "formatDuration"],
|
||||
},
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/utils/studioHelpers.ts",
|
||||
"exports": ["resolveDroppedAssetDimensions"],
|
||||
},
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/player/lib/timelineElementHelpers.ts",
|
||||
"exports": ["furthestClipEndFromSource", "readTimelineElementZIndex"],
|
||||
},
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/components/editor/manualOffsetDrag.ts",
|
||||
"exports": ["applyManualOffsetNudgeDraft", "applyManualOffsetNudgeCommit"],
|
||||
},
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/player/components/timelineEditing.ts",
|
||||
"exports": [
|
||||
"applyTimelineAutoScrollStep",
|
||||
"resolveTimelineAutoScrollLoopAction",
|
||||
"resolveTimelineDragEscape",
|
||||
"TimelineDragEscapeInput",
|
||||
],
|
||||
},
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/hooks/timelineEditingHelpers.ts",
|
||||
"exports": [
|
||||
"shiftGsapPositionsBatch",
|
||||
"syncTimingEditPreview",
|
||||
"GsapMutationOutcome",
|
||||
"readGsapMutationScriptText",
|
||||
],
|
||||
},
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/utils/timelineAssetDrop.ts",
|
||||
"exports": [
|
||||
"setCompositionDurationToContent",
|
||||
"extendCompositionDurationIfNeeded",
|
||||
"fitTimelineAssetGeometry",
|
||||
"resolveTimelineAssetCompositionSize",
|
||||
],
|
||||
},
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/player/components/timelineLayout.ts",
|
||||
"exports": [
|
||||
"getTimelineRowTop",
|
||||
"getTimelineRowFromY",
|
||||
"DRAG_EXTEND_MARGIN_PX",
|
||||
"getTimelineFitPps",
|
||||
"getTimelineDisplayContentWidth",
|
||||
"TRACKS_TOP_PAD",
|
||||
"MIN_TIMELINE_EXTENT_S",
|
||||
],
|
||||
},
|
||||
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
|
||||
{
|
||||
"file": "packages/studio/src/components/editor/domEditNudge.ts",
|
||||
@@ -331,6 +429,51 @@
|
||||
// require intrusive middleware changes beyond this PR's scope.
|
||||
"minLines": 6,
|
||||
"ignore": [
|
||||
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
|
||||
// studio-dnd pr22 removes this with the final config.
|
||||
"packages/studio/src/components/editor/domEditOverlayGeometry.ts",
|
||||
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
|
||||
// studio-dnd pr22 removes this with the final config.
|
||||
"packages/studio/src/components/nle/NLEContext.tsx",
|
||||
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
|
||||
// studio-dnd pr22 removes this with the final config.
|
||||
"packages/studio/src/components/nle/NLELayout.tsx",
|
||||
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
|
||||
// studio-dnd pr22 removes this with the final config.
|
||||
"packages/studio/src/components/nle/PreviewPane.tsx",
|
||||
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
|
||||
// studio-dnd pr22 removes this with the final config.
|
||||
"packages/studio/src/components/sidebar/AssetContextMenu.tsx",
|
||||
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
|
||||
// studio-dnd pr22 removes this with the final config.
|
||||
"packages/studio/src/hooks/timelineEditingHelpers.ts",
|
||||
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
|
||||
// studio-dnd pr22 removes this with the final config.
|
||||
"packages/studio/src/player/components/Timeline.tsx",
|
||||
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
|
||||
// studio-dnd pr22 removes this with the final config.
|
||||
"packages/studio/src/player/components/TimelineCanvas.tsx",
|
||||
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
|
||||
// studio-dnd pr22 removes this with the final config.
|
||||
"packages/studio/src/player/components/TimelineOverlays.tsx",
|
||||
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
|
||||
// studio-dnd pr22 removes this with the final config.
|
||||
"packages/studio/src/player/components/timelineClipDragTypes.ts",
|
||||
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
|
||||
// studio-dnd pr22 removes this with the final config.
|
||||
"packages/studio/src/player/components/useTimelineClipDrag.ts",
|
||||
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
|
||||
// studio-dnd pr22 removes this with the final config.
|
||||
"packages/studio/src/player/components/useTimelineMarqueeSelection.ts",
|
||||
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
|
||||
// studio-dnd pr22 removes this with the final config.
|
||||
"packages/studio/src/player/hooks/useTimelinePlayer.ts",
|
||||
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
|
||||
// studio-dnd pr22 removes this with the final config.
|
||||
"packages/studio/src/player/hooks/useTimelinePlayerLoop.ts",
|
||||
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
|
||||
// studio-dnd pr22 removes this with the final config.
|
||||
"packages/studio/src/player/lib/timelineElementHelpers.ts",
|
||||
// sourcePatcher.ts: pre-existing internal clones between the inline-style
|
||||
// and attribute tag-patchers; only the PatchOperation type gained two
|
||||
// optional fields here, but the line shift makes fallow re-flag them.
|
||||
@@ -456,6 +599,12 @@
|
||||
// complexity pre-dates the computed-timeline work. Exempted at file level
|
||||
// rather than refactored as scope creep.
|
||||
"ignore": [
|
||||
// TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
|
||||
// removed by studio-dnd/pr22 when the final config lands.
|
||||
"packages/studio/src/components/editor/domEditOverlayGeometry.ts",
|
||||
"packages/studio/src/components/editor/manualOffsetDrag.ts",
|
||||
"packages/studio/src/player/components/timelineEditing.ts",
|
||||
"packages/studio/src/player/lib/timelineElementHelpers.ts",
|
||||
// TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
|
||||
// removed by studio-dnd/pr22 when the final config lands.
|
||||
"packages/studio/src/player/components/TimelineOverlays.tsx",
|
||||
|
||||
@@ -27,6 +27,7 @@ import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEdi
|
||||
import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
|
||||
import type { ZOrderPatch } from "./canvasContextMenuZOrder";
|
||||
|
||||
// Re-exports for external consumers — preserving existing import paths.
|
||||
export {
|
||||
@@ -73,6 +74,7 @@ interface DomEditOverlayProps {
|
||||
onBoxSizeCommit: (
|
||||
selection: DomEditSelection,
|
||||
next: { width: number; height: number },
|
||||
offset?: { x: number; y: number },
|
||||
) => Promise<void> | void;
|
||||
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
|
||||
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
|
||||
@@ -81,6 +83,19 @@ interface DomEditOverlayProps {
|
||||
recordingState?: GestureRecordingState;
|
||||
onToggleRecording?: () => void;
|
||||
onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void;
|
||||
/**
|
||||
* Delete the selected canvas element.
|
||||
* Wire to handleDomEditElementDelete from useDomEditActionsContext —
|
||||
* same handler the Delete/Backspace hotkey uses.
|
||||
*/
|
||||
onDeleteSelection?: (selection: DomEditSelection) => void;
|
||||
/**
|
||||
* Called with the resolved z-order patch list after an optimistic DOM update.
|
||||
* The patch list is tie-aware and may include sibling elements (see
|
||||
* canvasContextMenuZOrder). Wire to handleDomZIndexReorderCommit from
|
||||
* useDomEditActionsContext. See CanvasContextMenu.tsx module comment.
|
||||
*/
|
||||
onApplyZIndex?: (selection: DomEditSelection, patches: ZOrderPatch[]) => void;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
|
||||
@@ -9,6 +9,14 @@ export interface OverlayRect {
|
||||
height: number;
|
||||
editScaleX: number;
|
||||
editScaleY: number;
|
||||
/**
|
||||
* The element's live transform rotation in DEGREES (screen/CSS convention, CW
|
||||
* positive), decomposed from its computed transform matrix. Present so the
|
||||
* selection chrome can render as an oriented bounding box (OBB) that co-rotates
|
||||
* with the element. Omitted (treated as 0) for group/union rects and when the
|
||||
* transform is unmeasurable — those render axis-aligned exactly as before.
|
||||
*/
|
||||
angle?: number;
|
||||
}
|
||||
|
||||
export interface GroupOverlayItem {
|
||||
@@ -98,31 +106,95 @@ export function toVisibleOverlayRect(
|
||||
return rect ? { ...rect, ...hugRectForElement(rect, element) } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The element's live transform rotation, in DEGREES (screen/CSS convention, CW
|
||||
* positive), decomposed from its computed transform matrix (rotation = atan2(b, a)).
|
||||
* GSAP folds rotation and scale into the same matrix; this reads rotation only.
|
||||
* Returns 0 when the transform is "none" or unmeasurable — callers treat 0 as
|
||||
* axis-aligned. Skew is ignored (does not affect atan2(b, a)).
|
||||
*/
|
||||
function readElementRotationDegrees(iframe: HTMLIFrameElement, element: HTMLElement): number {
|
||||
const win = iframe.contentWindow;
|
||||
if (!win) return 0;
|
||||
const DOMMatrixCtor = (win as Window & typeof globalThis).DOMMatrix;
|
||||
if (!DOMMatrixCtor) return 0;
|
||||
const transform = win.getComputedStyle(element).transform;
|
||||
if (!transform || transform === "none") return 0;
|
||||
let matrix: DOMMatrix;
|
||||
try {
|
||||
matrix = new DOMMatrixCtor(transform);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
const a = Number.isFinite(matrix.a) ? matrix.a : 1;
|
||||
const b = Number.isFinite(matrix.b) ? matrix.b : 0;
|
||||
const deg = (Math.atan2(b, a) * 180) / Math.PI;
|
||||
return Number.isFinite(deg) ? deg : 0;
|
||||
}
|
||||
|
||||
/** iframe→overlay mapping basis shared by every overlay-geometry function. */
|
||||
interface OverlayRootScale {
|
||||
iframeRect: DOMRect;
|
||||
overlayRect: DOMRect;
|
||||
rootScaleX: number;
|
||||
rootScaleY: number;
|
||||
}
|
||||
|
||||
/** The composition root element inside the preview doc (or null when absent). */
|
||||
function findOverlayRootElement(doc: Document | null): HTMLElement | null {
|
||||
return doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The root's effective width/height for scaling: prefer the composition's
|
||||
* declared dimensions (data-width/data-height), which stay fixed while GSAP
|
||||
* transforms mutate the measured rect; fall back to the measured rect. Null when
|
||||
* unmeasurable.
|
||||
*/
|
||||
function resolveRootDimensions(root: HTMLElement | null): { width: number; height: number } | null {
|
||||
if (!root) return null;
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const width = readPositiveDimension(root.getAttribute("data-width")) ?? rootRect.width;
|
||||
const height = readPositiveDimension(root.getAttribute("data-height")) ?? rootRect.height;
|
||||
if (!width || !height) return null;
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* The iframe/overlay client rects and the iframe→root scale factors. Uses the
|
||||
* composition's declared dimensions (data-width/data-height) for the scale
|
||||
* instead of rootRect.width/height: when GSAP applies transforms (scale,
|
||||
* translate) to the root, rootRect dimensions change but the composition's
|
||||
* canonical size stays fixed, and using rootRect misaligns the overlay during
|
||||
* animated playback. Returns null when the geometry is unmeasurable.
|
||||
*/
|
||||
function computeOverlayRootScale(
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
doc: Document | null,
|
||||
): OverlayRootScale | null {
|
||||
const iframeRect = iframe.getBoundingClientRect();
|
||||
const overlayRect = overlayEl.getBoundingClientRect();
|
||||
const dims = resolveRootDimensions(findOverlayRootElement(doc));
|
||||
if (!dims) return null;
|
||||
return {
|
||||
iframeRect,
|
||||
overlayRect,
|
||||
rootScaleX: iframeRect.width / dims.width,
|
||||
rootScaleY: iframeRect.height / dims.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function toOverlayRect(
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
element: HTMLElement,
|
||||
): OverlayRect | null {
|
||||
const iframeRect = iframe.getBoundingClientRect();
|
||||
const overlayRect = overlayEl.getBoundingClientRect();
|
||||
const doc = iframe.contentDocument;
|
||||
const root =
|
||||
doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
|
||||
const rootRect = root?.getBoundingClientRect();
|
||||
// Use the composition's declared dimensions (data-width/data-height) for scale
|
||||
// calculation instead of rootRect.width/height. When GSAP applies transforms
|
||||
// (scale, translate) to the root element, rootRect dimensions change but the
|
||||
// composition's canonical size stays the same. Using rootRect causes overlay
|
||||
// misalignment during animated playback.
|
||||
const declaredWidth = readPositiveDimension(root?.getAttribute("data-width") ?? null);
|
||||
const declaredHeight = readPositiveDimension(root?.getAttribute("data-height") ?? null);
|
||||
const rootWidth = declaredWidth ?? rootRect?.width;
|
||||
const rootHeight = declaredHeight ?? rootRect?.height;
|
||||
if (!rootWidth || !rootHeight || !rootRect) return null;
|
||||
const scale = computeOverlayRootScale(overlayEl, iframe, iframe.contentDocument);
|
||||
if (!scale) return null;
|
||||
const { iframeRect, overlayRect, rootScaleX, rootScaleY } = scale;
|
||||
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const rootScaleX = iframeRect.width / rootWidth;
|
||||
const rootScaleY = iframeRect.height / rootHeight;
|
||||
const sourceBoundary = findSourceBoundary(element);
|
||||
const sourceBoundaryRect = sourceBoundary?.getBoundingClientRect();
|
||||
const editScale = resolveDomEditCoordinateScale({
|
||||
@@ -144,7 +216,143 @@ export function toOverlayRect(
|
||||
};
|
||||
}
|
||||
|
||||
/** Which physical corner of the (possibly rotated) element a resize handle keeps
|
||||
* fixed: NW grabs the top-left, so the bottom-right (se) is the anchor, etc. */
|
||||
export type FixedCorner = "nw" | "ne" | "sw" | "se";
|
||||
|
||||
/**
|
||||
* The centroid (rendered center) of the four transformed corners from
|
||||
* `elementCornerOverlayPoints`, in overlay px. This is the element's true rotation
|
||||
* center — the point a center-anchored resize keeps planted.
|
||||
*/
|
||||
export function overlayCornersCentroid(corners: Record<FixedCorner, { x: number; y: number }>): {
|
||||
x: number;
|
||||
y: number;
|
||||
} {
|
||||
return {
|
||||
x: (corners.nw.x + corners.ne.x + corners.se.x + corners.sw.x) / 4,
|
||||
y: (corners.nw.y + corners.ne.y + corners.se.y + corners.sw.y) / 4,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The element's border-box corners in OVERLAY coordinates, honoring its live
|
||||
* transform (rotation/skew/scale) — NOT the axis-aligned getBoundingClientRect.
|
||||
* A rotated element's four visual corners are the transformed local box corners.
|
||||
* Uses the same iframe→overlay root scale as toOverlayRect so the returned
|
||||
* points share that function's coordinate space. Returns null when the
|
||||
* geometry is unmeasurable.
|
||||
*/
|
||||
export function elementCornerOverlayPoints(
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
element: HTMLElement,
|
||||
): Record<FixedCorner, { x: number; y: number }> | null {
|
||||
const win = iframe.contentWindow;
|
||||
const doc = iframe.contentDocument;
|
||||
if (!win || !doc) return null;
|
||||
const DOMMatrixCtor = (win as Window & typeof globalThis).DOMMatrix;
|
||||
const DOMPointCtor = (win as Window & typeof globalThis).DOMPoint;
|
||||
if (!DOMMatrixCtor || !DOMPointCtor) return null;
|
||||
|
||||
const scale = computeOverlayRootScale(overlayEl, iframe, doc);
|
||||
if (!scale) return null;
|
||||
const { iframeRect, overlayRect, rootScaleX, rootScaleY } = scale;
|
||||
|
||||
// The element's local border box maps to viewport coords by the SAME transform
|
||||
// matrix the browser used for its BCR. We recover the transform's screen-space
|
||||
// action from the BCR: transformPoint(localCorner - origin) gives a corner
|
||||
// RELATIVE to the transformed origin. We anchor those relative corners to the
|
||||
// BCR by matching the AABB of the transformed corners to the real BCR — the
|
||||
// constant offset cancels in the before/after difference the caller takes, but
|
||||
// we resolve it fully here so callers can also read absolute overlay positions.
|
||||
const cs = win.getComputedStyle(element);
|
||||
const w = element.offsetWidth;
|
||||
const h = element.offsetHeight;
|
||||
const originParts = cs.transformOrigin.split(" ").map((p) => Number.parseFloat(p));
|
||||
const ox = Number.isFinite(originParts[0]!) ? originParts[0]! : w / 2;
|
||||
const oy = Number.isFinite(originParts[1]!) ? originParts[1]! : h / 2;
|
||||
let matrix: DOMMatrix;
|
||||
try {
|
||||
matrix = new DOMMatrixCtor(cs.transform === "none" ? "" : cs.transform);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const rel = (lx: number, ly: number): { x: number; y: number } => {
|
||||
const p = matrix.transformPoint(new DOMPointCtor(lx - ox, ly - oy));
|
||||
return { x: p.x, y: p.y };
|
||||
};
|
||||
const relCorners = {
|
||||
nw: rel(0, 0),
|
||||
ne: rel(w, 0),
|
||||
se: rel(w, h),
|
||||
sw: rel(0, h),
|
||||
};
|
||||
// Recover the absolute viewport position by matching to the element's BCR:
|
||||
// the relative corners' AABB min corresponds to the BCR's top-left.
|
||||
const xs = [relCorners.nw.x, relCorners.ne.x, relCorners.se.x, relCorners.sw.x];
|
||||
const ys = [relCorners.nw.y, relCorners.ne.y, relCorners.se.y, relCorners.sw.y];
|
||||
const bcr = element.getBoundingClientRect();
|
||||
const dx = bcr.left - Math.min(...xs);
|
||||
const dy = bcr.top - Math.min(...ys);
|
||||
const toOverlay = (pt: { x: number; y: number }): { x: number; y: number } => ({
|
||||
x: iframeRect.left - overlayRect.left + (pt.x + dx) * rootScaleX,
|
||||
y: iframeRect.top - overlayRect.top + (pt.y + dy) * rootScaleY,
|
||||
});
|
||||
return {
|
||||
nw: toOverlay(relCorners.nw),
|
||||
ne: toOverlay(relCorners.ne),
|
||||
se: toOverlay(relCorners.se),
|
||||
sw: toOverlay(relCorners.sw),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection chrome's ORIENTED bounding box: the element's UNROTATED border box
|
||||
* expressed in overlay coordinates (center-anchored left/top/width/height) plus the
|
||||
* live rotation angle. Rendering that rect with `transform: rotate(angle)` about its
|
||||
* center reproduces the element's real transformed corners exactly, so the border,
|
||||
* corner dots, rotate handle, and crop pills all co-rotate with the object.
|
||||
*
|
||||
* Built from `elementCornerOverlayPoints` (the real transformed corners): the OBB
|
||||
* center is the corner centroid, the unrotated width/height are the edge lengths, and
|
||||
* left/top place the unrotated box so that rotating it about its center lands the
|
||||
* corners back on the measured points. At angle 0 this equals `toOverlayRect` (the
|
||||
* AABB and OBB coincide), so unrotated chrome is pixel-identical to today.
|
||||
*
|
||||
* Returns the plain AABB rect (angle 0) when the corner geometry can't be measured.
|
||||
*/
|
||||
export function orientedOverlayRect(
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
element: HTMLElement,
|
||||
): OverlayRect | null {
|
||||
const base = toOverlayRect(overlayEl, iframe, element);
|
||||
if (!base) return null;
|
||||
const corners = elementCornerOverlayPoints(overlayEl, iframe, element);
|
||||
if (!corners) return base;
|
||||
const angle = readElementRotationDegrees(iframe, element);
|
||||
// Unrotated edge lengths (in overlay px): nw→ne is the width, nw→sw the height.
|
||||
const width = Math.hypot(corners.ne.x - corners.nw.x, corners.ne.y - corners.nw.y);
|
||||
const height = Math.hypot(corners.sw.x - corners.nw.x, corners.sw.y - corners.nw.y);
|
||||
const centerX = (corners.nw.x + corners.se.x) / 2;
|
||||
const centerY = (corners.nw.y + corners.se.y) / 2;
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
left: centerX - width / 2,
|
||||
top: centerY - height / 2,
|
||||
width,
|
||||
height,
|
||||
editScaleX: base.editScaleX,
|
||||
editScaleY: base.editScaleY,
|
||||
angle,
|
||||
};
|
||||
}
|
||||
|
||||
const OVERLAY_RECT_EPSILON_PX = 0.5;
|
||||
const OVERLAY_RECT_ANGLE_EPSILON_DEG = 0.1;
|
||||
|
||||
export function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolean {
|
||||
if (a === b) return true;
|
||||
@@ -155,7 +363,8 @@ export function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolea
|
||||
Math.abs(a.width - b.width) < OVERLAY_RECT_EPSILON_PX &&
|
||||
Math.abs(a.height - b.height) < OVERLAY_RECT_EPSILON_PX &&
|
||||
Math.abs(a.editScaleX - b.editScaleX) < 0.001 &&
|
||||
Math.abs(a.editScaleY - b.editScaleY) < 0.001
|
||||
Math.abs(a.editScaleY - b.editScaleY) < 0.001 &&
|
||||
Math.abs((a.angle ?? 0) - (b.angle ?? 0)) < OVERLAY_RECT_ANGLE_EPSILON_DEG
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -228,3 +228,10 @@ export type UseDomEditOverlayGesturesOptions = {
|
||||
onCanvasMouseDown: (e: React.MouseEvent<HTMLDivElement>, o?: PreviewMouseDownOptions) => void;
|
||||
snapGuidesRef: RefObject<SnapGuidesState | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Corner identity for a chrome resize handle. Consumed by the selection
|
||||
* chrome + resize-local math; the gesture pipeline threads it through
|
||||
* startGesture options.
|
||||
*/
|
||||
export type ResizeHandle = "nw" | "ne" | "sw" | "se";
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import {
|
||||
type GestureKind,
|
||||
type GestureState,
|
||||
type ResizeHandle,
|
||||
type UseDomEditOverlayGesturesOptions,
|
||||
} from "./domEditOverlayGestures";
|
||||
import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
|
||||
@@ -100,7 +101,11 @@ export function startGesture(
|
||||
kind: GestureKind,
|
||||
e: React.PointerEvent<HTMLElement>,
|
||||
opts: UseDomEditOverlayGesturesOptions,
|
||||
options?: { selection?: DomEditSelection; rect?: OverlayRect | null },
|
||||
options?: {
|
||||
selection?: DomEditSelection;
|
||||
rect?: OverlayRect | null;
|
||||
resizeHandle?: ResizeHandle;
|
||||
},
|
||||
): boolean {
|
||||
const sel = options?.selection ?? opts.selectionRef.current;
|
||||
const rect = options?.rect ?? opts.overlayRectRef.current;
|
||||
|
||||
@@ -441,22 +441,26 @@ export function applyManualOffsetDragDraft(
|
||||
return offset;
|
||||
}
|
||||
|
||||
export function applyManualOffsetDragCommit(
|
||||
member: ManualOffsetDragMember,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): { x: number; y: number } {
|
||||
// Re-stamp the STABLE gesture-start base/offset before the source commit reads
|
||||
// them. A mid-drag re-render can wipe these attrs; the commit converts the drop
|
||||
// offset → gsap x/y via computeDraggedGsapPosition, which without the base falls
|
||||
// back to the live (already-dragged) transform and re-adds the delta — so the
|
||||
// element flies off-screen the instant you drop it. The member holds the true
|
||||
// gesture-start values in JS, immune to the re-render.
|
||||
/**
|
||||
* Re-stamp the STABLE gesture-start base/offset before the source commit reads
|
||||
* them. A mid-gesture re-render can wipe these attrs; the commit converts the
|
||||
* drop offset → gsap x/y via computeDraggedGsapPosition, which without the base
|
||||
* falls back to the live (already-dragged) transform and re-adds the delta — so
|
||||
* the element flies off-screen the instant you drop it. The member holds the
|
||||
* true gesture-start values in JS, immune to the re-render.
|
||||
*/
|
||||
function restampManualOffsetDragGestureBase(member: ManualOffsetDragMember): void {
|
||||
member.element.setAttribute("data-hf-drag-gsap-base-x", String(member.baseGsap.x));
|
||||
member.element.setAttribute("data-hf-drag-gsap-base-y", String(member.baseGsap.y));
|
||||
member.element.setAttribute("data-hf-drag-initial-offset-x", String(member.initialOffset.x));
|
||||
member.element.setAttribute("data-hf-drag-initial-offset-y", String(member.initialOffset.y));
|
||||
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
|
||||
}
|
||||
|
||||
function applyManualOffsetCommitValue(
|
||||
member: ManualOffsetDragMember,
|
||||
offset: { x: number; y: number },
|
||||
): { x: number; y: number } {
|
||||
restampManualOffsetDragGestureBase(member);
|
||||
// Optimistic visual through the GSAP channel (same as the live draft and the
|
||||
// committed `tl.set`), so the element holds its dropped position until the
|
||||
// source mutation soft-reloads — no transient CSS `--hf-studio-offset` write.
|
||||
@@ -467,6 +471,45 @@ export function applyManualOffsetDragCommit(
|
||||
return offset;
|
||||
}
|
||||
|
||||
export function applyManualOffsetDragCommit(
|
||||
member: ManualOffsetDragMember,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): { x: number; y: number } {
|
||||
return applyManualOffsetCommitValue(member, resolveManualOffsetDragMemberOffset(member, dx, dy));
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrow-key nudge, in OFFSET units (composition px), not screen px — "nudge
|
||||
* 1px" means one composition pixel regardless of canvas zoom, so the delta
|
||||
* adds to the gesture-start offset directly instead of going through the
|
||||
* screen→offset matrix. Draft/commit land in the same GSAP channel (with the
|
||||
* same CSS fallback) as the drag equivalents above.
|
||||
*/
|
||||
export function applyManualOffsetNudgeDraft(
|
||||
member: ManualOffsetDragMember,
|
||||
delta: { x: number; y: number },
|
||||
): { x: number; y: number } {
|
||||
const offset = {
|
||||
x: member.initialOffset.x + delta.x,
|
||||
y: member.initialOffset.y + delta.y,
|
||||
};
|
||||
if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
|
||||
applyStudioPathOffsetDraft(member.element, offset);
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
export function applyManualOffsetNudgeCommit(
|
||||
member: ManualOffsetDragMember,
|
||||
delta: { x: number; y: number },
|
||||
): { x: number; y: number } {
|
||||
return applyManualOffsetCommitValue(member, {
|
||||
x: member.initialOffset.x + delta.x,
|
||||
y: member.initialOffset.y + delta.y,
|
||||
});
|
||||
}
|
||||
|
||||
function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
|
||||
restoreStudioPathOffset(member.element, member.initialPathOffset);
|
||||
endStudioManualEditGesture(member.element, member.gestureToken);
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
hasDomEditRotationChanged,
|
||||
resolveDomEditResizeGesture,
|
||||
resolveDomEditRotationGesture,
|
||||
type ResizeHandle,
|
||||
} from "./domEditOverlayGestures";
|
||||
import {
|
||||
startGesture as _startGesture,
|
||||
@@ -88,7 +89,11 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
const startGesture = (
|
||||
kind: GestureKind,
|
||||
e: React.PointerEvent<HTMLElement>,
|
||||
options?: { selection?: DomEditSelection; rect?: OverlayRect | null },
|
||||
options?: {
|
||||
selection?: DomEditSelection;
|
||||
rect?: OverlayRect | null;
|
||||
resizeHandle?: ResizeHandle;
|
||||
},
|
||||
) => _startGesture(kind, e, opts, options);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
|
||||
@@ -6,6 +6,7 @@ export function ContextMenu({
|
||||
onCopy,
|
||||
onDelete,
|
||||
onRename,
|
||||
onAddAtPlayhead,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -14,6 +15,7 @@ export function ContextMenu({
|
||||
onCopy: (path: string) => void;
|
||||
onDelete?: (path: string) => void;
|
||||
onRename?: (oldPath: string, newPath: string) => void;
|
||||
onAddAtPlayhead?: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -28,6 +30,19 @@ export function ContextMenu({
|
||||
className="absolute bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[140px] text-xs"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
{onAddAtPlayhead && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddAtPlayhead(asset);
|
||||
onClose();
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Add at playhead
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -30,6 +30,35 @@ export function ext(path: string): string {
|
||||
return dot > 0 ? name.slice(dot + 1).toUpperCase() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to at most `maxLen` chars, preserving the start and end.
|
||||
* Middle characters are replaced with an ellipsis. If the string is short
|
||||
* enough it is returned unchanged.
|
||||
*
|
||||
* @example truncateMiddle("2a37eabf-long-uuid-887d8.mp4", 20) → "2a37eabf-…887d8.mp4"
|
||||
*
|
||||
* Pure — unit-tested.
|
||||
*/
|
||||
export function truncateMiddle(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
const keep = maxLen - 1; // 1 char for ellipsis
|
||||
const tail = Math.floor(keep / 3);
|
||||
const head = keep - tail;
|
||||
return str.slice(0, head) + "…" + str.slice(str.length - tail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration in seconds as MM:SS. Returns an empty string for
|
||||
* non-positive, NaN, or Infinity values. Pure — unit-tested.
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return "";
|
||||
const total = Math.round(seconds);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export const CATEGORY_LABELS: Record<MediaCategory, string> = {
|
||||
audio: "Audio",
|
||||
images: "Images",
|
||||
|
||||
@@ -98,6 +98,15 @@ export function useDomEditActionsContext(): DomEditActionsValue {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional access — returns null outside a provider. Lets the player-package
|
||||
* <Timeline> (a public standalone export) reach the z-order persist path when
|
||||
* embedded in the NLE without hard-requiring the provider in standalone/test mounts.
|
||||
*/
|
||||
export function useDomEditActionsContextOptional(): DomEditActionsValue | null {
|
||||
return useContext(DomEditActionsContext);
|
||||
}
|
||||
|
||||
export function useDomEditSelectionContext(): DomEditSelectionValue {
|
||||
const ctx = useContext(DomEditSelectionContext);
|
||||
if (!ctx) throw new Error("useDomEditSelectionContext must be used within DomEditProvider");
|
||||
|
||||
@@ -56,6 +56,15 @@ export function useStudioShellContext(): StudioShellValue {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional access — returns null outside a provider. Lets the player-package
|
||||
* <Timeline> (a public standalone export) read shell state when embedded in the
|
||||
* NLE without hard-requiring the provider in standalone/test mounts.
|
||||
*/
|
||||
export function useStudioShellContextOptional(): StudioShellValue | null {
|
||||
return useContext(StudioShellContext);
|
||||
}
|
||||
|
||||
export function useStudioPlaybackContext(): StudioPlaybackValue {
|
||||
const ctx = useContext(StudioPlaybackContext);
|
||||
if (!ctx) throw new Error("useStudioPlaybackContext must be used within StudioPlaybackProvider");
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { patchDocumentRootDuration } from "./timelineEditingHelpers";
|
||||
|
||||
// The regression these guard against: timing edits now soft-reload instead of
|
||||
// full-reloading the iframe, so the runtime recomputes the composition length
|
||||
// from the ROOT's `data-duration` and posts it back — overwriting the studio's
|
||||
// optimistic duration readout. Patching the live root's `data-duration` in the
|
||||
// same tick keeps the runtime's post-soft-reload report in agreement, so the
|
||||
// readout stays live on grow AND shrink instead of snapping back.
|
||||
function parse(html: string): Document {
|
||||
return new DOMParser().parseFromString(html, "text/html");
|
||||
}
|
||||
|
||||
describe("patchDocumentRootDuration", () => {
|
||||
it("writes the content end onto the TOP-LEVEL composition root (grow)", () => {
|
||||
const doc = parse(`
|
||||
<div data-composition-id="root" data-duration="15.18">
|
||||
<div data-composition-id="scene" data-start="0" data-duration="8"></div>
|
||||
<div data-start="12" data-duration="8"></div>
|
||||
</div>
|
||||
`);
|
||||
expect(patchDocumentRootDuration(doc, 20)).toBe(true);
|
||||
const root = doc.querySelector('[data-composition-id="root"]');
|
||||
expect(root?.getAttribute("data-duration")).toBe("20");
|
||||
// Nested sub-composition is untouched — only the outermost root is the length.
|
||||
expect(doc.querySelector('[data-composition-id="scene"]')?.getAttribute("data-duration")).toBe(
|
||||
"8",
|
||||
);
|
||||
});
|
||||
|
||||
it("shrinks the root duration too (not grow-only)", () => {
|
||||
const doc = parse(`<div data-composition-id="root" data-duration="15.18"></div>`);
|
||||
expect(patchDocumentRootDuration(doc, 6.5)).toBe(true);
|
||||
expect(doc.querySelector("[data-composition-id]")?.getAttribute("data-duration")).toBe("6.5");
|
||||
});
|
||||
|
||||
it("picks the composition with no ancestor composition as the root", () => {
|
||||
// Root appears AFTER a nested one in document order — selection must be by
|
||||
// ancestry, not first-match, matching the runtime's own root resolver.
|
||||
const doc = parse(`
|
||||
<section data-composition-id="root" data-duration="10">
|
||||
<div data-composition-id="inner" data-duration="4"></div>
|
||||
</section>
|
||||
`);
|
||||
patchDocumentRootDuration(doc, 12);
|
||||
expect(doc.querySelector('[data-composition-id="root"]')?.getAttribute("data-duration")).toBe(
|
||||
"12",
|
||||
);
|
||||
expect(doc.querySelector('[data-composition-id="inner"]')?.getAttribute("data-duration")).toBe(
|
||||
"4",
|
||||
);
|
||||
});
|
||||
|
||||
it("no-ops on a non-positive or non-finite content end (never collapses to 0)", () => {
|
||||
const doc = parse(`<div data-composition-id="root" data-duration="15.18"></div>`);
|
||||
expect(patchDocumentRootDuration(doc, 0)).toBe(false);
|
||||
expect(patchDocumentRootDuration(doc, -3)).toBe(false);
|
||||
expect(patchDocumentRootDuration(doc, Number.NaN)).toBe(false);
|
||||
expect(doc.querySelector("[data-composition-id]")?.getAttribute("data-duration")).toBe("15.18");
|
||||
});
|
||||
|
||||
it("no-ops when there is no composition root and reports false", () => {
|
||||
const doc = parse(`<div data-start="0" data-duration="5"></div>`);
|
||||
expect(patchDocumentRootDuration(doc, 10)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a null document", () => {
|
||||
expect(patchDocumentRootDuration(null, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { applySoftReload } from "../utils/gsapSoftReload";
|
||||
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
|
||||
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
|
||||
import {
|
||||
@@ -237,6 +238,16 @@ export function buildTimelineMoveTimingPatch(
|
||||
start: number,
|
||||
duration: number,
|
||||
): string {
|
||||
// Coexistence-window guard: a caller resolving the legacy context handler can
|
||||
// receive the new engine's {element, updates} change shape and read a field
|
||||
// that doesn't exist — start arrives undefined/NaN and would be serialized as
|
||||
// data-start="NaN" into the file. Never persist a non-finite timing value.
|
||||
if (!Number.isFinite(start) || !Number.isFinite(duration)) {
|
||||
console.warn(
|
||||
`[Timeline] buildTimelineMoveTimingPatch: non-finite timing (start=${start}, duration=${duration}) — patch skipped`,
|
||||
);
|
||||
return original;
|
||||
}
|
||||
const patched = applyPatchByTarget(original, target, {
|
||||
type: "attribute",
|
||||
property: "start",
|
||||
@@ -597,3 +608,130 @@ export function foldedScaleGsapMutation(input: {
|
||||
|
||||
// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
|
||||
export { applyPatchByTarget, formatTimelineAttributeNumber };
|
||||
|
||||
/**
|
||||
* Pure: find the TOP-LEVEL composition root in `doc` (the `[data-composition-id]`
|
||||
* with no ancestor composition, matching the runtime's own root resolution) and
|
||||
* write `contentEnd` into its `data-duration`. Returns whether a write happened.
|
||||
*
|
||||
* A timing edit optimistically patches the moved clip's `data-start`/`-duration`
|
||||
* in the live iframe, but NOT the root's `data-duration`. Timing edits now take
|
||||
* the soft-reload path (no full iframe reload), which re-runs the GSAP script and
|
||||
* lets the runtime recompute the composition length — it reads the root's
|
||||
* `data-duration` as the authored floor (core/runtime/init.ts) and posts it back,
|
||||
* so the studio store's duration is set from the STALE root and the readout
|
||||
* reverts to the pre-edit length. Patching the root here keeps the runtime's
|
||||
* post-soft-reload duration report in agreement with the optimistic readout, so
|
||||
* the number stays live (grow AND shrink) instead of snapping back.
|
||||
*/
|
||||
export function patchDocumentRootDuration(
|
||||
doc: Document | null | undefined,
|
||||
contentEnd: number,
|
||||
): boolean {
|
||||
if (!doc || !Number.isFinite(contentEnd) || contentEnd <= 0) return false;
|
||||
const nodes = Array.from(doc.querySelectorAll("[data-composition-id]"));
|
||||
const root =
|
||||
nodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ?? nodes[0] ?? null;
|
||||
if (!root) return false;
|
||||
root.setAttribute("data-duration", formatTimelineAttributeNumber(contentEnd));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Best-effort live-iframe wrapper for patchDocumentRootDuration (see above). */
|
||||
export function patchIframeRootDuration(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
contentEnd: number,
|
||||
): void {
|
||||
try {
|
||||
patchDocumentRootDuration(iframe?.contentDocument ?? null, contentEnd);
|
||||
} catch {
|
||||
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched counterpart to shiftGsapPositions: shift several elements' GSAP tween
|
||||
* positions in ONE server write (the `shift-positions-batch` op). Used by the
|
||||
* atomic multi-clip persist so a ripple/insert shifts every affected clip's
|
||||
* tweens together instead of one racing round-trip per clip. No-op when every
|
||||
* delta is 0 or the list is empty.
|
||||
*/
|
||||
export async function shiftGsapPositionsBatch(
|
||||
projectId: string,
|
||||
filePath: string,
|
||||
shifts: Array<{ elementId: string; delta: number }>,
|
||||
): Promise<GsapMutationOutcome> {
|
||||
const payload = shifts
|
||||
.filter((s) => s.elementId && Number.isFinite(s.delta) && s.delta !== 0)
|
||||
.map((s) => ({ targetSelector: `#${s.elementId}`, delta: s.delta }));
|
||||
if (payload.length === 0) return { scriptText: null };
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "shift-positions-batch", shifts: payload }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error((err as { error?: string })?.error ?? "shift-positions-batch failed");
|
||||
}
|
||||
return { scriptText: readGsapMutationScriptText(await res.json().catch(() => null)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the live preview after a TIMING-ONLY edit (move / resize), preferring a
|
||||
* soft reload over the full iframe reload that flashes every clip.
|
||||
*
|
||||
* Why this is safe WITHOUT re-deriving timeline elements: a move/resize commit has
|
||||
* already (a) patched the live DOM timing attributes, (b) updated the store's
|
||||
* elements optimistically (useTimelineClipDrag calls `updateElement` before the
|
||||
* persist), and (c) had the server rewrite the GSAP tween positions — which is the
|
||||
* `scriptText` we swap in here. `applySoftReload` re-runs that script in the LIVE
|
||||
* document (no navigation), re-seeks to the current playhead, and rebinds the
|
||||
* timeline, so the runtime matches the already-correct store. Nothing structural
|
||||
* changed (no clip added/removed), so `processTimelineMessage` would re-derive the
|
||||
* identical element set — skipping it just avoids the flash.
|
||||
*
|
||||
* Escalates to the full `reloadPreview()` only on the PERMANENT `cannot-soft-reload`
|
||||
* result (no gsap runtime / rebind hook / scopable key / script element, or the
|
||||
* re-run threw). The TRANSIENT `verify-failed` is NOT escalated — the live re-run
|
||||
* already applied the shift; a remount would re-flash for nothing. When the server
|
||||
* returned no `scriptText` (older server, multi-script comp), we also full-reload.
|
||||
*/
|
||||
export function syncTimingEditPreview(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
outcome: GsapMutationOutcome,
|
||||
currentTime: number,
|
||||
reloadPreview: () => void,
|
||||
): void {
|
||||
if (!iframe || !outcome.scriptText) {
|
||||
reloadPreview();
|
||||
return;
|
||||
}
|
||||
const result = applySoftReload(iframe, outcome.scriptText, {
|
||||
onAsyncFailure: reloadPreview,
|
||||
currentTimeOverride: currentTime,
|
||||
});
|
||||
if (result === "cannot-soft-reload") reloadPreview();
|
||||
}
|
||||
|
||||
// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
|
||||
|
||||
/**
|
||||
* The bits of the server GSAP-mutation response the timeline edit path needs.
|
||||
* `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload`
|
||||
* swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null
|
||||
* when the endpoint didn't return one (older server, or a multi-script comp the
|
||||
* soft path can't scope), in which case the caller full-reloads as before.
|
||||
*/
|
||||
export interface GsapMutationOutcome {
|
||||
scriptText: string | null;
|
||||
}
|
||||
|
||||
function readGsapMutationScriptText(body: unknown): string | null {
|
||||
if (typeof body !== "object" || body === null) return null;
|
||||
const text = (body as { scriptText?: unknown }).scriptText;
|
||||
return typeof text === "string" ? text : null;
|
||||
}
|
||||
|
||||
@@ -253,6 +253,8 @@ export const Timeline = memo(function Timeline({
|
||||
if (
|
||||
!draggedClip?.started ||
|
||||
trackOrder.length === 0 ||
|
||||
draggedClip.previewLayerId === undefined ||
|
||||
draggedClip.previewLayerIndex === undefined ||
|
||||
trackOrder.includes(draggedClip.previewLayerId)
|
||||
)
|
||||
return trackOrder;
|
||||
|
||||
@@ -39,10 +39,15 @@ export interface TimelineEditCallbacks {
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => Promise<void> | void;
|
||||
onMoveElements?: (
|
||||
changes: TimelineGroupMoveChange[],
|
||||
/**
|
||||
* Batched move. Method syntax (bivariant) + union parameter so both the
|
||||
* legacy group-editing shape (TimelineGroupMoveChange) and the NLE edit
|
||||
* shape (TimelineGroupMoveEdit) type-check while both engines coexist.
|
||||
*/
|
||||
onMoveElements?(
|
||||
changes: Array<TimelineGroupMoveChange | TimelineGroupMoveEdit>,
|
||||
options?: TimelineGroupCommitOptions,
|
||||
) => Promise<void> | void;
|
||||
): Promise<void> | void;
|
||||
onResizeElements?: (
|
||||
changes: TimelineGroupResizeChange[],
|
||||
options?: TimelineGroupCommitOptions,
|
||||
@@ -66,3 +71,13 @@ export interface TimelineEditCallbacks {
|
||||
) => void;
|
||||
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* NLE batched-move edit: the element plus exactly the fields the move changes.
|
||||
* The legacy engine's TimelineGroupMoveChange carries resolved absolute values
|
||||
* instead; onMoveElements accepts either while the engines coexist.
|
||||
*/
|
||||
export interface TimelineGroupMoveEdit {
|
||||
element: TimelineElement;
|
||||
updates: Pick<TimelineElement, "start" | "track">;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { TimelineLayerId } from "./timelineTrackOrder";
|
||||
|
||||
interface DragPreviewState {
|
||||
element: Pick<TimelineElement, "track">;
|
||||
previewLayerId: TimelineLayerId;
|
||||
previewLayerIndex: number;
|
||||
// Optional during the NLE coexistence window: the intermediate
|
||||
// DraggedClipState carries these only on the legacy drag path.
|
||||
previewLayerId?: TimelineLayerId;
|
||||
previewLayerIndex?: number;
|
||||
}
|
||||
|
||||
export interface TimelineMovePreview {
|
||||
@@ -28,8 +30,8 @@ export function resolveDragPreviewPlacement(
|
||||
): {
|
||||
previewStart: number;
|
||||
previewTrack: number;
|
||||
previewLayerId: TimelineLayerId;
|
||||
previewLayerIndex: number;
|
||||
previewLayerId: TimelineLayerId | undefined;
|
||||
previewLayerIndex: number | undefined;
|
||||
previewStackingReorder: TimelineStackingReorderIntent | null;
|
||||
} {
|
||||
if (groupMove.active) {
|
||||
|
||||
@@ -525,3 +525,72 @@ export function buildTimelineElementAgentPrompt(element: {
|
||||
export function formatTimelineAttributeNumber(value: number): string {
|
||||
return Number(roundToCentiseconds(value).toFixed(2)).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one edge auto-scroll step: scroll `scroll` toward the edge zone the
|
||||
* pointer is in, clamped to the scrollable range. Returns true when the
|
||||
* container actually moved (the caller keeps its RAF running and re-runs its
|
||||
* live preview), false when the pointer is outside the edge zones or the scroll
|
||||
* is already clamped (the caller stops).
|
||||
*/
|
||||
export function applyTimelineAutoScrollStep(
|
||||
scroll: HTMLElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): boolean {
|
||||
const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
|
||||
if (delta.x === 0 && delta.y === 0) return false;
|
||||
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
||||
const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
|
||||
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
|
||||
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
|
||||
if (nextScrollLeft === scroll.scrollLeft && nextScrollTop === scroll.scrollTop) return false;
|
||||
scroll.scrollLeft = nextScrollLeft;
|
||||
scroll.scrollTop = nextScrollTop;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an edge auto-scroll RAF loop should start, stop, or stay as-is
|
||||
* for the current pointer: "start" when the pointer is in an edge zone and no
|
||||
* loop is running, "stop" when it left the zones while a loop is running,
|
||||
* "none" otherwise.
|
||||
*/
|
||||
export function resolveTimelineAutoScrollLoopAction(
|
||||
scroll: HTMLElement | null,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
rafActive: boolean,
|
||||
): "start" | "stop" | "none" {
|
||||
if (!scroll) return "none";
|
||||
const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
|
||||
if (delta.x === 0 && delta.y === 0) return rafActive ? "stop" : "none";
|
||||
return rafActive ? "none" : "start";
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape cancels an in-progress clip drag / resize / blocked-drag: no commit,
|
||||
* no undo entry — the previews live only in the gesture state, so clearing it
|
||||
* restores the pre-drag timeline. `suppressClick` arms the click suppressor
|
||||
* only when the gesture actually started, so the click generated by the
|
||||
* eventual pointerup can't reselect or split the clip.
|
||||
*/
|
||||
export function resolveTimelineDragEscape(input: TimelineDragEscapeInput): {
|
||||
cancel: boolean;
|
||||
suppressClick: boolean;
|
||||
} {
|
||||
if (input.key !== "Escape" || (!input.drag && !input.resize && !input.blocked)) {
|
||||
return { cancel: false, suppressClick: false };
|
||||
}
|
||||
return {
|
||||
cancel: true,
|
||||
suppressClick: Boolean(input.drag?.started || input.resize?.started || input.blocked?.started),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TimelineDragEscapeInput {
|
||||
key: string;
|
||||
drag: { started: boolean } | null;
|
||||
resize: { started: boolean } | null;
|
||||
blocked: { started: boolean } | null;
|
||||
}
|
||||
|
||||
@@ -256,3 +256,91 @@ export function resolveTimelineAssetDrop(
|
||||
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The y (content-space) of the top edge of track ROW index `row` (0 = first
|
||||
* displayed lane). The single source of truth for row→y — the ruler height plus
|
||||
* the top breathing pad plus whole track lanes above it. Every clip/ghost/
|
||||
* placeholder/insertion top and every pointer-y→row inversion goes through this
|
||||
* (or its inverse in {@link getTimelineRowFromY}) so the pad can never drift.
|
||||
*/
|
||||
export function getTimelineRowTop(row: number): number {
|
||||
return RULER_H + TRACKS_TOP_PAD + row * TRACK_H;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of {@link getTimelineRowTop}: the fractional row index for a content-
|
||||
* space y (used for insert-row / drop-lane decisions). Subtracts the ruler and
|
||||
* top pad before dividing by the track height.
|
||||
*/
|
||||
export function getTimelineRowFromY(contentY: number): number {
|
||||
return (contentY - RULER_H - TRACKS_TOP_PAD) / TRACK_H;
|
||||
}
|
||||
|
||||
/**
|
||||
* While a clip drag is live, the rendered timeline extends this far past the
|
||||
* ghost's end so the right-edge auto-scroll zone always has room to keep
|
||||
* stepping — that's what lets a drag extend the timeline past its current
|
||||
* rendered width (see Timeline.tsx displayContentWidth).
|
||||
*/
|
||||
export const DRAG_EXTEND_MARGIN_PX = 160;
|
||||
|
||||
/**
|
||||
* Fit-mode pixels-per-second: fill the viewport with the composition, but
|
||||
* never map fewer than MIN_TIMELINE_EXTENT_S seconds onto it — a short comp
|
||||
* takes a fraction of the width and the remaining ruler runs to 1:00.
|
||||
* Manual zoom multiplies this base, so the floor only anchors the default.
|
||||
*/
|
||||
export function getTimelineFitPps(viewportWidth: number, effectiveDuration: number): number {
|
||||
const safeDuration =
|
||||
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
|
||||
const span = Math.max(safeDuration, MIN_TIMELINE_EXTENT_S);
|
||||
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER) return 100;
|
||||
return (viewportWidth - GUTTER - 2) / span;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rendered timeline extent in px. Always covers, whichever is largest:
|
||||
* the actual clip content, the visible viewport (no dead black after short
|
||||
* content — CapCut-style), a live drag or resize ghost plus the auto-scroll
|
||||
* margin (drag/trim-to-extend), and the MIN_TIMELINE_EXTENT_S floor. Only the
|
||||
* RENDERED extent grows; clip positions/durations are untouched.
|
||||
*/
|
||||
export function getTimelineDisplayContentWidth(input: {
|
||||
trackContentWidth: number;
|
||||
viewportWidth: number;
|
||||
pps: number;
|
||||
dragGhostEndPx?: number;
|
||||
resizeGhostEndPx?: number;
|
||||
}): number {
|
||||
const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
|
||||
return Math.max(
|
||||
input.trackContentWidth,
|
||||
input.viewportWidth - GUTTER - 2,
|
||||
input.dragGhostEndPx ?? 0,
|
||||
input.resizeGhostEndPx ?? 0,
|
||||
MIN_TIMELINE_EXTENT_S * safePps,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Breathing room INSIDE the scroll area (CapCut-style), threaded through every
|
||||
* track-row y computation via {@link getTimelineRowTop} — never inline a magic
|
||||
* offset; a track row's top is always `RULER_H + TRACKS_TOP_PAD + row*TRACK_H`.
|
||||
*
|
||||
* - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track
|
||||
* (~half a track height) so the first clip isn't jammed under the ruler.
|
||||
* - TRACKS_BOTTOM_PAD: empty space below the last track (~1.5 track heights),
|
||||
* enough to comfortably drag a clip into the void to create a new bottom lane.
|
||||
*/
|
||||
export const TRACKS_TOP_PAD = 50;
|
||||
|
||||
/**
|
||||
* The rendered timeline always spans at least this many seconds of ruler +
|
||||
* track lanes, even when the composition is shorter — the empty space on the
|
||||
* right is a real, drag/drop-enabled surface (clips can be moved into it; the
|
||||
* composition grows on commit, content-driven). In fit mode the fit pps is
|
||||
* derived against this floor, so a 10s comp renders as ~1/6 of the viewport
|
||||
* with 60s of ruler after it.
|
||||
*/
|
||||
export const MIN_TIMELINE_EXTENT_S = 60;
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
// the rest of the callback bag still comes from TimelineEditContext.
|
||||
export type TimelineEditOverrides = Pick<
|
||||
TimelineEditCallbacks,
|
||||
"onMoveElement" | "onResizeElement" | "onBlockedEditAttempt" | "onSplitElement"
|
||||
"onMoveElement" | "onMoveElements" | "onResizeElement" | "onBlockedEditAttempt" | "onSplitElement"
|
||||
>;
|
||||
|
||||
// Merge any prop overrides over the context callbacks. Used so NLELayout can
|
||||
@@ -16,15 +16,17 @@ export function useResolvedTimelineEditCallbacks(
|
||||
overrides: TimelineEditOverrides,
|
||||
): TimelineEditCallbacks {
|
||||
const ctx = useTimelineEditContextOptional();
|
||||
const { onMoveElement, onResizeElement, onBlockedEditAttempt, onSplitElement } = overrides;
|
||||
const { onMoveElement, onMoveElements, onResizeElement, onBlockedEditAttempt, onSplitElement } =
|
||||
overrides;
|
||||
return useMemo(
|
||||
() => ({
|
||||
...ctx,
|
||||
onMoveElement: onMoveElement ?? ctx.onMoveElement,
|
||||
onMoveElements: onMoveElements ?? ctx.onMoveElements,
|
||||
onResizeElement: onResizeElement ?? ctx.onResizeElement,
|
||||
onBlockedEditAttempt: onBlockedEditAttempt ?? ctx.onBlockedEditAttempt,
|
||||
onSplitElement: onSplitElement ?? ctx.onSplitElement,
|
||||
}),
|
||||
[ctx, onMoveElement, onResizeElement, onBlockedEditAttempt, onSplitElement],
|
||||
[ctx, onMoveElement, onMoveElements, onResizeElement, onBlockedEditAttempt, onSplitElement],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TimelineSnapType } from "./timelineSnapping";
|
||||
import { useRef, useState, useCallback, useMemo } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import {
|
||||
@@ -41,14 +42,23 @@ export interface DraggedClipState {
|
||||
pointerOffsetY: number;
|
||||
previewStart: number;
|
||||
previewTrack: number;
|
||||
previewLayerId: TimelineLayerId;
|
||||
previewLayerIndex: number;
|
||||
previewLayerId?: TimelineLayerId;
|
||||
previewLayerIndex?: number;
|
||||
/** Beat time the clip will snap to on drop, for the grid-line highlight. */
|
||||
snapBeatTime: number | null;
|
||||
snapGuideTime: number | null;
|
||||
snapGuideKind: TimelineSnapKind | null;
|
||||
snapBeatTime?: number | null;
|
||||
snapGuideTime?: number | null;
|
||||
snapGuideKind?: TimelineSnapKind | null;
|
||||
/** Sibling-scoped z-index reorder intent resolved from the vertical drag. */
|
||||
previewStackingReorder: TimelineStackingReorderIntent | null;
|
||||
previewStackingReorder?: TimelineStackingReorderIntent | null;
|
||||
/**
|
||||
* When non-null, the drop inserts a NEW track at this visual row boundary
|
||||
* (0 = above the top lane) instead of landing on previewTrack. Optional —
|
||||
* populated only by the lane-zone drag path (TimelineLanes).
|
||||
*/
|
||||
insertRow?: number | null;
|
||||
/** Snap target the clip will land on, for the guide highlight (lane-zone drag path). */
|
||||
snapTime?: number | null;
|
||||
snapType?: TimelineSnapType | null;
|
||||
started: boolean;
|
||||
}
|
||||
|
||||
@@ -56,11 +66,16 @@ export interface ResizingClipState {
|
||||
element: TimelineElement;
|
||||
edge: "start" | "end";
|
||||
originClientX: number;
|
||||
/**
|
||||
* scrollLeft at gesture start. Optional so pre-existing constructors/tests
|
||||
* stay valid; when absent the first preview update captures the current value.
|
||||
*/
|
||||
originScrollLeft?: number;
|
||||
previewStart: number;
|
||||
previewDuration: number;
|
||||
previewPlaybackStart?: number;
|
||||
snapGuideTime: number | null;
|
||||
snapGuideKind: TimelineSnapKind | null;
|
||||
snapGuideTime?: number | null;
|
||||
snapGuideKind?: TimelineSnapKind | null;
|
||||
started: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,31 @@ import { isFinitePositive } from "./playbackAdapter";
|
||||
// Duration attribute helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read a host element's effective CSS stacking order for the timeline's reverse
|
||||
* z→lane mapping. Prefers the inline `style.zIndex` (what the canvas context
|
||||
* menu and LayersPanel z-edits write via handleDomZIndexReorderCommit), falls
|
||||
* back to computed style; "auto" / empty / unparseable ⇒ 0. Works with a
|
||||
* detached parse Document (no defaultView) as well as a live iframe. Mirrors
|
||||
* canvasContextMenuZOrder.parseZIndex semantics so the two directions agree.
|
||||
*/
|
||||
export function readTimelineElementZIndex(el: Element): number {
|
||||
const html = el as HTMLElement;
|
||||
const parseZ = (value: string | null | undefined): number | null => {
|
||||
if (value == null || value === "" || value === "auto") return null;
|
||||
const n = Number.parseInt(value, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
const fromInline = parseZ(html.style?.zIndex);
|
||||
if (fromInline != null) return fromInline;
|
||||
const view = el.ownerDocument?.defaultView;
|
||||
if (view?.getComputedStyle) {
|
||||
const fromComputed = parseZ(view.getComputedStyle(html).zIndex);
|
||||
if (fromComputed != null) return fromComputed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function readDurationAttribute(el: Element | null | undefined): number {
|
||||
if (!el) return 0;
|
||||
const duration =
|
||||
@@ -36,14 +61,20 @@ export function isTimelineIgnoredElement(el: Element): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
|
||||
/**
|
||||
* Furthest clip end (start + RAW `data-duration`) over every non-root clip in the
|
||||
* document. Reads the authored attribute, NOT any runtime-computed value — so it
|
||||
* is immune to the runtime's clamp that truncates a clip's live duration to the
|
||||
* composition length. This is the source of truth for content-driven duration:
|
||||
* computing it from the store instead would feed the truncated value back in and
|
||||
* make the composition length ratchet down (research HANDOFF-3 §6.1 feedback loop).
|
||||
*/
|
||||
export function furthestClipEndFromDocument(doc: Document | null | undefined): number {
|
||||
if (!doc) return 0;
|
||||
const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
|
||||
if (rootDuration > 0) return rootDuration;
|
||||
|
||||
const root = doc.querySelector("[data-composition-id]");
|
||||
let maxEnd = 0;
|
||||
for (const node of Array.from(doc.querySelectorAll("[data-start]"))) {
|
||||
if (isTimelineIgnoredElement(node)) continue;
|
||||
if (node === root || isTimelineIgnoredElement(node)) continue;
|
||||
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
|
||||
const duration = readDurationAttribute(node);
|
||||
if (!Number.isFinite(start) || start < 0 || duration <= 0) continue;
|
||||
@@ -52,6 +83,24 @@ export function readTimelineDurationFromDocument(doc: Document | null | undefine
|
||||
return maxEnd;
|
||||
}
|
||||
|
||||
export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
|
||||
if (!doc) return 0;
|
||||
const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
|
||||
if (rootDuration > 0) return rootDuration;
|
||||
return furthestClipEndFromDocument(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Furthest clip end parsed straight from a composition SOURCE STRING (the HTML
|
||||
* being saved). Uses raw `data-duration`, so it is the correct input for syncing
|
||||
* the root duration after an edit — reading the store instead would use the
|
||||
* runtime-truncated durations and shrink the composition (the feedback loop).
|
||||
*/
|
||||
export function furthestClipEndFromSource(source: string): number {
|
||||
if (!source) return 0;
|
||||
return furthestClipEndFromDocument(new DOMParser().parseFromString(source, "text/html"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM element type guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
import { computePinnedZoomPercent } from "../components/timelineZoom";
|
||||
|
||||
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
|
||||
export interface KeyframeCacheEntry {
|
||||
@@ -142,6 +143,25 @@ interface PlayerState {
|
||||
|
||||
/** Multi-select: additional selected elements beyond selectedElementId. */
|
||||
selectedElementIds: Set<string>;
|
||||
clearSelectedElementIds: () => void;
|
||||
/** Replace the whole multi-selection at once (marquee live updates). */
|
||||
setSelectedElementIds: (ids: Set<string>) => void;
|
||||
/** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
|
||||
timelineSnapEnabled: boolean;
|
||||
setTimelineSnapEnabled: (enabled: boolean) => void;
|
||||
/**
|
||||
* Pin the timeline zoom to its current visual scale before a duration-changing
|
||||
* edit, so a subsequent duration change (which recomputes fit-pps) stops
|
||||
* rescaling every clip. No-op once already pinned (mode is "manual").
|
||||
*/
|
||||
pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void;
|
||||
/**
|
||||
* The timeline's live pixels-per-second + fit basis, published by <Timeline> on
|
||||
* every render. Non-reactive scratch state (never read as a render input).
|
||||
*/
|
||||
timelinePps: number;
|
||||
timelineFitPps: number;
|
||||
setTimelineScale: (pps: number, fitPps: number) => void;
|
||||
setSelection: (ids: Iterable<string>, anchor?: string | null) => void;
|
||||
addSelectedElementId: (id: string) => void;
|
||||
toggleSelectedElementId: (id: string) => void;
|
||||
@@ -260,6 +280,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
loopEnabled: false,
|
||||
zoomMode: "fit",
|
||||
manualZoomPercent: 100,
|
||||
timelinePps: 100,
|
||||
timelineFitPps: 100,
|
||||
inPoint: null,
|
||||
outPoint: null,
|
||||
|
||||
@@ -383,6 +405,32 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
},
|
||||
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
|
||||
setZoomMode: (mode) => set({ zoomMode: mode }),
|
||||
clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
|
||||
setSelectedElementIds: (ids: Set<string>) => set({ selectedElementIds: new Set(ids) }),
|
||||
timelineSnapEnabled: readStudioUiPreferences().timelineSnapEnabled ?? true,
|
||||
setTimelineSnapEnabled: (enabled) => {
|
||||
writeStudioUiPreferences({ timelineSnapEnabled: enabled });
|
||||
set({ timelineSnapEnabled: enabled });
|
||||
},
|
||||
pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) =>
|
||||
set((s) => {
|
||||
// Already pinned (or the user manually zoomed) — never clobber that.
|
||||
if (s.zoomMode === "manual") return {};
|
||||
const percent = computePinnedZoomPercent(currentPixelsPerSecond, fitPixelsPerSecond);
|
||||
writeStudioUiPreferences({
|
||||
timelineZoomMode: "manual",
|
||||
timelineManualZoomPercent: percent,
|
||||
});
|
||||
return { zoomMode: "manual", manualZoomPercent: percent };
|
||||
}),
|
||||
setTimelineScale: (pps, fitPps) => {
|
||||
// Non-reactive publish: mutate in place + reuse the same object identity so no
|
||||
// subscriber re-renders (these fields are never a render input, only read
|
||||
// imperatively before pinning).
|
||||
const state = get();
|
||||
state.timelinePps = pps;
|
||||
state.timelineFitPps = fitPps;
|
||||
},
|
||||
setInPoint: (time) =>
|
||||
set((state) => {
|
||||
const t = time !== null && Number.isFinite(time) ? time : null;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extendRootDurationInSource } from "./rootDuration";
|
||||
import {
|
||||
extendRootDurationInSource,
|
||||
patchRootCompositionDuration,
|
||||
readRootCompositionDuration,
|
||||
} from "./rootDuration";
|
||||
|
||||
describe("extendRootDurationInSource", () => {
|
||||
it("extends data-duration when the new end is bigger than the root duration", () => {
|
||||
@@ -31,4 +36,88 @@ describe("extendRootDurationInSource", () => {
|
||||
expect(patched).toContain(`<div data-duration="3"></div>`);
|
||||
expect(patched).toContain(`<div data-composition-id="main" data-duration="7"></div>`);
|
||||
});
|
||||
|
||||
// Reviewer round-2 finding #3: the old regex was attribute-ORDER-dependent and
|
||||
// double-quotes-only, so these hand-authored variants silently no-op'd.
|
||||
it("extends when data-duration is declared BEFORE data-composition-id", () => {
|
||||
const source = `<div data-duration="4" data-composition-id="main"></div>`;
|
||||
expect(extendRootDurationInSource(source, 9)).toBe(
|
||||
`<div data-duration="9" data-composition-id="main"></div>`,
|
||||
);
|
||||
});
|
||||
|
||||
it("extends when attributes use single quotes", () => {
|
||||
const source = `<div data-composition-id='main' data-duration='4'></div>`;
|
||||
expect(extendRootDurationInSource(source, 9)).toBe(
|
||||
`<div data-composition-id='main' data-duration='9'></div>`,
|
||||
);
|
||||
});
|
||||
|
||||
it("extends with swapped order AND single quotes AND extra whitespace", () => {
|
||||
const source = `<div data-duration = '4' data-composition-id = 'main' >x</div>`;
|
||||
expect(extendRootDurationInSource(source, 9)).toBe(
|
||||
`<div data-duration = '9' data-composition-id = 'main' >x</div>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readRootCompositionDuration", () => {
|
||||
it("reads the root duration regardless of attribute order or quote style", () => {
|
||||
expect(
|
||||
readRootCompositionDuration(`<div data-composition-id="main" data-duration="4"></div>`),
|
||||
).toBe(4);
|
||||
expect(
|
||||
readRootCompositionDuration(`<div data-duration="4" data-composition-id="main"></div>`),
|
||||
).toBe(4);
|
||||
expect(
|
||||
readRootCompositionDuration(`<div data-composition-id='main' data-duration='4.5'></div>`),
|
||||
).toBe(4.5);
|
||||
});
|
||||
|
||||
it("reads the FIRST composition when several are present", () => {
|
||||
const source = [
|
||||
`<div data-composition-id="root" data-duration="10"></div>`,
|
||||
`<div data-composition-id="nested" data-duration="2"></div>`,
|
||||
].join("\n");
|
||||
expect(readRootCompositionDuration(source)).toBe(10);
|
||||
});
|
||||
|
||||
it("returns null when there is no composition root", () => {
|
||||
expect(readRootCompositionDuration(`<div data-duration="4"></div>`)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the root has no data-duration attribute", () => {
|
||||
expect(readRootCompositionDuration(`<div data-composition-id="main"></div>`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchRootCompositionDuration", () => {
|
||||
it("rewrites only the root's data-duration value, preserving surrounding bytes", () => {
|
||||
const source = [
|
||||
`<!doctype html>`,
|
||||
`<div data-composition-id="main" data-duration="4" data-width="640">`,
|
||||
` <img src="a.png" data-duration="3" />`,
|
||||
`</div>`,
|
||||
].join("\n");
|
||||
const patched = patchRootCompositionDuration(source, "8");
|
||||
expect(patched).toBe(
|
||||
[
|
||||
`<!doctype html>`,
|
||||
`<div data-composition-id="main" data-duration="8" data-width="640">`,
|
||||
` <img src="a.png" data-duration="3" />`,
|
||||
`</div>`,
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps single-quote style when rewriting", () => {
|
||||
expect(
|
||||
patchRootCompositionDuration(`<div data-composition-id='main' data-duration='4'></div>`, "8"),
|
||||
).toBe(`<div data-composition-id='main' data-duration='8'></div>`);
|
||||
});
|
||||
|
||||
it("is a no-op when the root has no data-duration attribute", () => {
|
||||
const source = `<div data-composition-id="main"></div>`;
|
||||
expect(patchRootCompositionDuration(source, "8")).toBe(source);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,80 @@
|
||||
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
||||
|
||||
/**
|
||||
* Matches the opening tag of the ROOT composition element — the first tag that
|
||||
* carries a `data-composition-id` attribute, regardless of where the attribute
|
||||
* sits in the tag or how its value is quoted. `[^>]*` keeps the match inside a
|
||||
* single tag, so the first hit is the first composition in document order (the
|
||||
* same element `doc.querySelector("[data-composition-id]")` resolves to).
|
||||
*/
|
||||
const ROOT_COMPOSITION_OPEN_TAG_RE = /<[^>]*\bdata-composition-id(?=[\s=/>])[^>]*>/i;
|
||||
|
||||
/**
|
||||
* Matches a `data-duration="..."` attribute inside a single opening tag. Quote
|
||||
* style is captured (backreferenced), so both `"` and `'` round-trip, and the
|
||||
* `\s*` around `=` tolerates author whitespace.
|
||||
*/
|
||||
const DATA_DURATION_ATTR_RE = /(\bdata-duration\s*=\s*)(["'])[^"']*\2/i;
|
||||
|
||||
/**
|
||||
* Read the ROOT composition's raw `data-duration`.
|
||||
*
|
||||
* Parses the source with DOMParser and locates the root the same way the rest of
|
||||
* the timeline code does — the first `[data-composition-id]` element in document
|
||||
* order — then reads its `data-duration`. Because it works on the parsed tree,
|
||||
* attribute order and quote style are irrelevant, unlike the previous
|
||||
* order-dependent, double-quotes-only regex.
|
||||
*
|
||||
* Returns `null` when there is no root composition or the root has no
|
||||
* `data-duration` attribute at all. When the attribute is present but not a
|
||||
* number the parsed `NaN` is returned as-is, so callers reproduce the old
|
||||
* regex's "attribute matched but value unusable" behavior.
|
||||
*
|
||||
* Deterministic and render-safe: DOMParser is the only DOM global used.
|
||||
*/
|
||||
export function readRootCompositionDuration(source: string): number | null {
|
||||
const root = new DOMParser()
|
||||
.parseFromString(source, "text/html")
|
||||
.querySelector("[data-composition-id]");
|
||||
const raw = root?.getAttribute("data-duration");
|
||||
if (raw == null) return null;
|
||||
return Number.parseFloat(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the ROOT composition's `data-duration` value, preserving the rest of
|
||||
* the document byte-for-byte.
|
||||
*
|
||||
* We deliberately do NOT re-serialize the parsed Document: a DOMParser round-trip
|
||||
* injects `<html>/<head>/<body>`, forces double quotes, self-closes void
|
||||
* elements, and drops the original indentation — it reformats unrelated markup.
|
||||
* Instead we locate the root opening tag and rewrite only its `data-duration`
|
||||
* value in place, keeping the author's quote style. The targeted splice is
|
||||
* attribute-order-, quote-, and whitespace-agnostic.
|
||||
*
|
||||
* No-op (returns `source` unchanged) when there is no root composition tag or the
|
||||
* root tag has no `data-duration` attribute to replace.
|
||||
*/
|
||||
export function patchRootCompositionDuration(source: string, newValue: string): string {
|
||||
const rootTag = ROOT_COMPOSITION_OPEN_TAG_RE.exec(source);
|
||||
if (!rootTag) return source;
|
||||
const patchedTag = rootTag[0].replace(
|
||||
DATA_DURATION_ATTR_RE,
|
||||
(_full, prefix: string, quote: string) => `${prefix}${quote}${newValue}${quote}`,
|
||||
);
|
||||
if (patchedTag === rootTag[0]) return source;
|
||||
return (
|
||||
source.slice(0, rootTag.index) + patchedTag + source.slice(rootTag.index + rootTag[0].length)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grow-only ratchet: extend the root composition's `data-duration` to `newEnd`
|
||||
* when `newEnd` is larger than the current root duration. No-op otherwise (and
|
||||
* when there is no root duration to compare against).
|
||||
*/
|
||||
export function extendRootDurationInSource(source: string, newEnd: number): string {
|
||||
const rootDurMatch = source.match(
|
||||
/(<[^>]*data-composition-id="[^"]*"[^>]*data-duration=")([^"]*)(")/,
|
||||
);
|
||||
if (rootDurMatch) {
|
||||
const rootDur = parseFloat(rootDurMatch[2]!);
|
||||
if (newEnd > rootDur) {
|
||||
return source.replace(
|
||||
rootDurMatch[0],
|
||||
`${rootDurMatch[1]}${formatTimelineAttributeNumber(newEnd)}${rootDurMatch[3]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return source;
|
||||
const current = readRootCompositionDuration(source);
|
||||
if (current == null || !(newEnd > current)) return source;
|
||||
return patchRootCompositionDuration(source, formatTimelineAttributeNumber(newEnd));
|
||||
}
|
||||
|
||||
@@ -308,3 +308,65 @@ export async function resolveDroppedAssetDuration(
|
||||
media.load();
|
||||
return duration;
|
||||
}
|
||||
|
||||
export async function resolveDroppedAssetDimensions(
|
||||
projectId: string,
|
||||
assetPath: string,
|
||||
kind: TimelineAssetKind,
|
||||
): Promise<{ width: number; height: number } | null> {
|
||||
if (kind === "audio") return null;
|
||||
const src = `/api/projects/${projectId}/preview/${assetPath}`;
|
||||
|
||||
if (kind === "image") {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const timeout = window.setTimeout(() => resolve(null), 3000);
|
||||
img.addEventListener(
|
||||
"load",
|
||||
() => {
|
||||
window.clearTimeout(timeout);
|
||||
resolve(
|
||||
img.naturalWidth > 0 && img.naturalHeight > 0
|
||||
? { width: img.naturalWidth, height: img.naturalHeight }
|
||||
: null,
|
||||
);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
img.addEventListener(
|
||||
"error",
|
||||
() => {
|
||||
window.clearTimeout(timeout);
|
||||
resolve(null);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const video = document.createElement("video");
|
||||
video.preload = "metadata";
|
||||
const timeout = window.setTimeout(() => resolve(null), 3000);
|
||||
const finalize = (value: { width: number; height: number } | null) => {
|
||||
window.clearTimeout(timeout);
|
||||
video.src = "";
|
||||
video.load();
|
||||
resolve(value);
|
||||
};
|
||||
video.addEventListener(
|
||||
"loadedmetadata",
|
||||
() => {
|
||||
finalize(
|
||||
video.videoWidth > 0 && video.videoHeight > 0
|
||||
? { width: video.videoWidth, height: video.videoHeight }
|
||||
: null,
|
||||
);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
video.addEventListener("error", () => finalize(null), { once: true });
|
||||
video.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
|
||||
import { patchRootCompositionDuration, readRootCompositionDuration } from "./rootDuration";
|
||||
import { roundToCenti } from "./rounding";
|
||||
import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
|
||||
|
||||
@@ -98,6 +99,8 @@ export function resolveTimelineAssetInitialGeometry(source: string): {
|
||||
|
||||
export function buildTimelineAssetInsertHtml(input: {
|
||||
id: string;
|
||||
/** Stable hf-id stamped as data-hf-id by the NLE drop path (optional in the legacy path). */
|
||||
hfId?: string;
|
||||
assetPath: string;
|
||||
kind: TimelineAssetKind;
|
||||
start: number;
|
||||
@@ -136,3 +139,62 @@ export function insertTimelineAssetIntoSource(source: string, assetHtml: string)
|
||||
.join("\n");
|
||||
return `${source.slice(0, insertAt)}\n${childIndent}${indented}${source.slice(insertAt)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the
|
||||
* timeline length tracks content — the content-driven counterpart to
|
||||
* extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can
|
||||
* reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
|
||||
* an empty timeline keeps its declared duration instead of collapsing to 0.
|
||||
*/
|
||||
export function setCompositionDurationToContent(source: string, contentEnd: number): string {
|
||||
if (!Number.isFinite(contentEnd) || contentEnd <= 0) return source;
|
||||
const rootDur = readRootCompositionDuration(source);
|
||||
if (rootDur == null) return source;
|
||||
const next = roundToCenti(contentEnd);
|
||||
if (rootDur === next) return source;
|
||||
return patchRootCompositionDuration(source, String(next));
|
||||
}
|
||||
|
||||
export function extendCompositionDurationIfNeeded(source: string, requiredEnd: number): string {
|
||||
const rootDur = readRootCompositionDuration(source);
|
||||
if (rootDur == null || !Number.isFinite(rootDur) || requiredEnd <= rootDur) return source;
|
||||
return patchRootCompositionDuration(source, String(roundToCenti(requiredEnd)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the
|
||||
* timeline length tracks content — the content-driven counterpart to
|
||||
* extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can
|
||||
* reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
|
||||
* an empty timeline keeps its declared duration instead of collapsing to 0.
|
||||
*/
|
||||
export function fitTimelineAssetGeometry(
|
||||
natural: { width: number; height: number } | null,
|
||||
comp: { width: number; height: number },
|
||||
): { left: number; top: number; width: number; height: number } {
|
||||
if (!natural || natural.width <= 0 || natural.height <= 0) {
|
||||
return { left: 0, top: 0, width: comp.width, height: comp.height };
|
||||
}
|
||||
const scale = Math.min(1, comp.width / natural.width, comp.height / natural.height);
|
||||
const width = Math.round(natural.width * scale);
|
||||
const height = Math.round(natural.height * scale);
|
||||
return {
|
||||
left: Math.round((comp.width - width) / 2),
|
||||
top: Math.round((comp.height - height) / 2),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTimelineAssetCompositionSize(source: string): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const width = Number.parseFloat(source.match(/\bdata-width=(["'])([^"']+)\1/i)?.[2] ?? "");
|
||||
const height = Number.parseFloat(source.match(/\bdata-height=(["'])([^"']+)\1/i)?.[2] ?? "");
|
||||
return {
|
||||
width: Number.isFinite(width) && width > 0 ? Math.round(width) : 640,
|
||||
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 360,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user