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:
ukimsanov
2026-07-11 18:22:21 -07:00
parent 6a03b3ca7c
commit ee487d00e5
26 changed files with 1340 additions and 70 deletions
+149
View File
@@ -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;
+90 -1
View File
@@ -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);
});
});
+76 -13
View File
@@ -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,
};
}