Files
heygen-com--hyperframes/packages/studio/src/components/editor/domEditOverlayCrop.ts
T
wehub-resource-sync 85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

213 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { parseInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
export type CropEdge = "top" | "right" | "bottom" | "left";
export interface CropScreenRect {
left: number;
top: number;
width: number;
height: number;
}
/** Element-space insets → the cropped region in overlay (screen) space. */
export function cropRectFromInsets(
rect: CropScreenRect,
insets: ClipPathInsetSides,
scaleX: number,
scaleY: number,
): CropScreenRect {
const sx = scaleX > 0 ? scaleX : 1;
const sy = scaleY > 0 ? scaleY : 1;
const left = rect.left + insets.left * sx;
const top = rect.top + insets.top * sy;
return {
left,
top,
width: Math.max(0, rect.width - (insets.left + insets.right) * sx),
height: Math.max(0, rect.height - (insets.top + insets.bottom) * sy),
};
}
/**
* Current inset crop of an element (inline first, computed fallback).
* Zeros = no clip (croppable, nothing cropped yet). `null` = the element
* carries a clip-path this tool cannot represent (circle/polygon/non-px
* inset) — croppers must not lift, edit, or restore it, or the clip gets
* silently replaced or destroyed on deselect.
*/
export function readElementCropInsets(
element: HTMLElement,
): (ClipPathInsetSides & { radius: number }) | null {
const inline = element.style.getPropertyValue("clip-path").trim();
const value =
inline || element.ownerDocument.defaultView?.getComputedStyle(element).clipPath.trim() || "";
if (!value || value === "none") return { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
return parseInsetClipPathSides(value);
}
export interface CropInsetDragInput {
edge: CropEdge;
startInsets: ClipPathInsetSides;
deltaX: number;
deltaY: number;
scaleX: number;
scaleY: number;
width: number;
height: number;
}
function clampInset(value: number, max: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(Math.max(0, value), Math.max(0, max));
}
export function resolveCropInsetFromEdgeDrag(input: CropInsetDragInput): ClipPathInsetSides {
const scaleX = input.scaleX > 0 ? input.scaleX : 1;
const scaleY = input.scaleY > 0 ? input.scaleY : 1;
const next = { ...input.startInsets };
if (input.edge === "left") {
next.left = clampInset(
input.startInsets.left + input.deltaX / scaleX,
input.width - next.right,
);
} else if (input.edge === "right") {
next.right = clampInset(
input.startInsets.right - input.deltaX / scaleX,
input.width - next.left,
);
} else if (input.edge === "top") {
next.top = clampInset(
input.startInsets.top + input.deltaY / scaleY,
input.height - next.bottom,
);
} else {
next.bottom = clampInset(
input.startInsets.bottom - input.deltaY / scaleY,
input.height - next.top,
);
}
return next;
}
/** Pan the crop window: opposing insets shift together so the crop size stays
* constant, clamped inside the element bounds. Repositions which part of the
* element shows through a fixed-size crop (the center "reposition" handle). */
export function resolveCropInsetFromMoveDrag(input: {
startInsets: ClipPathInsetSides;
deltaX: number;
deltaY: number;
scaleX: number;
scaleY: number;
}): ClipPathInsetSides {
const sx = input.scaleX > 0 ? input.scaleX : 1;
const sy = input.scaleY > 0 ? input.scaleY : 1;
const totalX = input.startInsets.left + input.startInsets.right;
const totalY = input.startInsets.top + input.startInsets.bottom;
const left = Math.min(Math.max(0, input.startInsets.left + input.deltaX / sx), totalX);
const top = Math.min(Math.max(0, input.startInsets.top + input.deltaY / sy), totalY);
return { left, right: totalX - left, top, bottom: totalY - top };
}
/** Display-only hug: shrink a projected rect by the element's inset crop.
* For rects nothing writes back to (e.g. the hover ring). */
export function hugRectForElement(
rect: CropScreenRect & { editScaleX: number; editScaleY: number },
element: HTMLElement,
): CropScreenRect {
const insets = readElementCropInsets(element);
// Uneditable clip (null) can't be hugged — show the full element rect.
if (!insets || (insets.top <= 0 && insets.right <= 0 && insets.bottom <= 0 && insets.left <= 0))
return rect;
return cropRectFromInsets(rect, insets, rect.editScaleX, rect.editScaleY);
}
/**
* The element's own (unrotated) box in overlay space, plus the rotation to
* apply when drawing crop UI over it. `clip-path` applies in the element's
* LOCAL frame — before its transform — so the crop dim/outline/handles must be
* drawn rotated with the element, not on its axis-aligned bounding box: an
* AABB-drawn dim visually "straightens" a rotated element by masking its
* corners (the crop window looks axis-aligned while the pixels are not).
*
* scaleX/scaleY are overlay px per element CSS px (element's own scale × the
* editor zoom), so element-space insets map straight onto the frame. Assumes
* the default 50%/50% transform-origin (the GSAP/studio convention). 3D or
* unparseable transforms fall back to the axis-aligned frame (angle 0, AABB
* box) — the pre-existing presentation.
*/
export interface CropFrame {
angleDeg: number;
left: number;
top: number;
width: number;
height: number;
scaleX: number;
scaleY: number;
}
export function readElementCropFrame(
element: HTMLElement,
overlayRect: CropScreenRect & { editScaleX: number; editScaleY: number },
): CropFrame {
const editX = overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
const editY = overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
const aabb: CropFrame = {
angleDeg: 0,
left: overlayRect.left,
top: overlayRect.top,
width: overlayRect.width,
height: overlayRect.height,
scaleX: editX,
scaleY: editY,
};
let transform = "";
try {
transform = element.ownerDocument.defaultView?.getComputedStyle(element).transform ?? "";
} catch {
return aabb;
}
if (!transform || transform === "none") return aabb;
const m = /^matrix\(([^)]+)\)$/.exec(transform);
if (!m) return aabb; // matrix3d or unparseable → axis-aligned fallback
const [a, b, c, d] = m[1]!.split(",").map((v) => Number.parseFloat(v));
if (![a, b, c, d].every(Number.isFinite)) return aabb;
const elScaleX = Math.hypot(a!, b!);
const det = a! * d! - b! * c!;
const elScaleY = elScaleX !== 0 ? det / elScaleX : 1;
if (elScaleX <= 0 || elScaleY <= 0) return aabb;
const angleDeg = (Math.atan2(b!, a!) * 180) / Math.PI;
const scaleX = elScaleX * editX;
const scaleY = elScaleY * editY;
const width = element.offsetWidth * scaleX;
const height = element.offsetHeight * scaleY;
if (!(width > 0) || !(height > 0)) return aabb;
// Rotation about the default center keeps the center invariant, so the
// local box is centered on the AABB center.
const cx = overlayRect.left + overlayRect.width / 2;
const cy = overlayRect.top + overlayRect.height / 2;
return {
angleDeg,
left: cx - width / 2,
top: cy - height / 2,
width,
height,
scaleX,
scaleY,
};
}
/** Rotate a screen-space pointer delta into the element's local frame. */
export function rotateDeltaIntoFrame(
deltaX: number,
deltaY: number,
angleDeg: number,
): { deltaX: number; deltaY: number } {
if (angleDeg === 0) return { deltaX, deltaY };
const rad = (-angleDeg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return { deltaX: deltaX * cos - deltaY * sin, deltaY: deltaX * sin + deltaY * cos };
}