From 76b3fa26af115d3bda1e8f6bcc521e9fe9af6f98 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 2 Jul 2026 10:26:18 +0800 Subject: [PATCH] feat(preview): on-canvas Crop overlay + Inspector crop toggle & aspect-lock menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port upstream CropOverlayView.swift + InspectorView cropRow/cropMenu (T3-11): while crop-editing is active for the selected visual clip, the preview canvas swaps TransformOverlay for a CropOverlay — crop rect with dimmed outside, rule- of-thirds guides, 4 corner handles (free or aspect-locked), drag-inside pan — committing once on release (animated crop upserts a keyframe at the playhead). - cropOverlay.ts: pure 1:1 drag math — pannedCrop, resizedCrop (free clamps insets >= 0 with MIN_VISIBLE floor exactly like upstream :144-147; locked drives one visible-width var with per-corner bounds), lockedAspectNormalized, CropAspectLock presets + cropFittingAspect/cropForPreset. 32 unit tests. - CropOverlay.tsx: TransformOverlay's architecture (pointerEvents:none container, window pointer listeners + cleanup, sampledTransform/cropAt rest state, commit-on-release; rotated clips route corner deltas via rotateDeltaIntoLocalFrame). - Preview.tsx: mutually exclusive mount per PreviewContainerView.swift:37-41. - uiStore: cropEditingActive + cropAspectLock; cleared on selection change and on leaving the Video tab (InspectorView.swift:60-68 parity). - Inspector CropSection: crop-edit toggle + aspect preset menu; applyCropPreset branches animated (upsert keyframe) vs static, free = lock-only (no mutation). - dict.ts: zh/en labels. Note: the implementing agent died mid-run (API disconnect); work was recovered, 3 of its tests expected impossible negative insets on an outward drag from the identity crop — rewritten against a non-zero start plus an explicit floor-at-0 assertion (upstream clamps identically). Verified: pnpm build (tsc) clean; pnpm test 324 passed. On-canvas drag still needs a real-machine visual pass. --- web/src/components/inspector/Inspector.tsx | 76 ++++- web/src/components/preview/CropOverlay.tsx | 328 +++++++++++++++++++++ web/src/components/preview/Preview.tsx | 51 +++- web/src/i18n/dict.ts | 24 ++ web/src/lib/clip.test.ts | 39 +++ web/src/lib/clip.ts | 31 ++ web/src/lib/cropOverlay.test.ts | 255 ++++++++++++++++ web/src/lib/cropOverlay.ts | 305 +++++++++++++++++++ web/src/store/uiStore.ts | 33 ++- 9 files changed, 1130 insertions(+), 12 deletions(-) create mode 100644 web/src/components/preview/CropOverlay.tsx create mode 100644 web/src/lib/cropOverlay.test.ts create mode 100644 web/src/lib/cropOverlay.ts diff --git a/web/src/components/inspector/Inspector.tsx b/web/src/components/inspector/Inspector.tsx index 7d1408b..86cb934 100644 --- a/web/src/components/inspector/Inspector.tsx +++ b/web/src/components/inspector/Inspector.tsx @@ -11,6 +11,7 @@ import { useEffect, useState } from "react"; import { CircleDashed, + Crop as CropIcon, Diamond, Info, Palette, @@ -50,6 +51,7 @@ import { scaleKeyframeValue, volumeKeyframeValue, } from "../../lib/keyframeValue"; +import { CROP_ASPECT_LOCKS, cropForPreset, type CropAspectLock } from "../../lib/cropOverlay"; import { FS, RADIUS, SPACE } from "../../lib/theme"; import { useT, type TFunction } from "../../i18n"; import type { @@ -295,6 +297,13 @@ function ClipInspector({ timeline.width, timeline.height, ); + // Raw SOURCE pixel aspect (sourceWidth / sourceHeight) for the Crop aspect-lock + // menu — distinct from `aspect` above (canvas-normalized). 1:1 with upstream + // `sourcePixelAspect(for:)` (CropOverlayView.swift:207-212). + const sourcePixelAspect = + mediaItem?.width && mediaItem?.height && mediaItem.height > 0 + ? mediaItem.width / mediaItem.height + : null; const commit = (props: Parameters[1]) => edit.setClipProperties([clip.id], props); @@ -464,6 +473,7 @@ function ClipInspector({ animated={cropAnimated} activeFrame={activeFrame} commit={commit} + sourcePixelAspect={sourcePixelAspect} t={t} /> @@ -590,7 +600,18 @@ function PositionSection({ ); } -// MARK: - Crop section (4 edge insets, 0–1) +// MARK: - Crop section (on-canvas toggle + aspect preset + 4 edge insets, 0–1) + +const CROP_ASPECT_LABEL_KEY: Record = { + free: "inspector.cropAspect.free", + original: "inspector.cropAspect.original", + r16x9: "inspector.cropAspect.r16x9", + r9x16: "inspector.cropAspect.r9x16", + r1x1: "inspector.cropAspect.r1x1", + r4x3: "inspector.cropAspect.r4x3", + r3x4: "inspector.cropAspect.r3x4", + r21x9: "inspector.cropAspect.r21x9", +}; function CropSection({ clip, @@ -598,6 +619,7 @@ function CropSection({ animated, activeFrame, commit, + sourcePixelAspect, t, }: { clip: Clip; @@ -605,8 +627,29 @@ function CropSection({ animated: boolean; activeFrame: number; commit: (props: Parameters[1]) => void; + sourcePixelAspect: number | null; t: TFunction; }) { + const cropEditingActive = useEditorUiStore((s) => s.cropEditingActive); + const toggleCropEditingActive = useEditorUiStore((s) => s.toggleCropEditingActive); + const cropAspectLock = useEditorUiStore((s) => s.cropAspectLock); + const setCropAspectLock = useEditorUiStore((s) => s.setCropAspectLock); + + // 1:1 port of `applyCropPreset(_:on:)` (InspectorView.swift:851-863): `free` + // only updates the lock state (no crop mutation — the user keeps the current + // shape and drags freely); `original` resets to the identity crop; the sized + // presets commit the largest centered crop matching that pixel aspect. + const applyCropPreset = (preset: CropAspectLock) => { + setCropAspectLock(preset); + const next = cropForPreset(preset, sourcePixelAspect); + if (next === null) return; + if (animated) { + void edit.upsertKeyframe(clip.id, "crop", activeFrame, { kind: "crop", value: next }); + } else { + commit({ crop: next }); + } + }; + const commitEdge = (edge: keyof Crop, v: number) => { const next: Crop = { ...clip.crop, [edge]: v }; commit({ crop: next }); @@ -637,6 +680,37 @@ function CropSection({ return (
+ + + + + + {renderEdge(t("inspector.field.cropLeft"), "left", sampledCrop.left)} {renderEdge(t("inspector.field.cropTop"), "top", sampledCrop.top)} {renderEdge(t("inspector.field.cropRight"), "right", sampledCrop.right)} diff --git a/web/src/components/preview/CropOverlay.tsx b/web/src/components/preview/CropOverlay.tsx new file mode 100644 index 0000000..993e7f5 --- /dev/null +++ b/web/src/components/preview/CropOverlay.tsx @@ -0,0 +1,328 @@ +/** + * CropOverlay (T3-11). On-canvas Crop manipulation for the single selected + * visual clip: a crop rectangle with the outside region dimmed, rule-of-thirds + * guide lines, 4 corner resize handles, and drag-inside-to-pan — 1:1 port of + * upstream `CropOverlayView.swift`'s ACTUAL behavior. Mounted by `Preview.tsx` + * in place of `TransformOverlay` while `cropEditingActive` is true + * (`PreviewContainerView.swift:37-41`'s `if cropEditingActive { CropOverlayView() } + * else { TransformOverlayView() }` — the two overlays are mutually exclusive, + * never both on screen). + * + * Same architecture as the sibling `TransformOverlay.tsx`: a `pointerEvents:none` + * container with interactive children only, window pointermove/up listeners + + * a cleanup ref + an unmount-safety effect, local-optimistic drag preview, and + * ONE commit on release (`applyCrop`/`commitCrop`'s upstream split — + * CropOverlayView.swift:76-87,113-125 — collapses here to local state + one + * commit, matching how `TransformOverlay` already collapses upstream's + * apply/commit split for Transform). + * + * Pure geometry (pan/resize/aspect-lock math) lives in `../../lib/cropOverlay.ts` + * so it's independently unit-tested; this file only wires pointer events, pixel + * conversion, and the render tree. + */ + +import { useEffect, useRef, useState } from "react"; +import { useEditorUiStore } from "../../store/uiStore"; +import * as edit from "../../store/editActions"; +import { cropAt, rotateDeltaIntoLocalFrame, sampledTransform } from "../../lib/clip"; +import { + cropAspectLockPixelAspect, + lockedAspectNormalized, + pannedCrop, + resizedCrop, + type CropResizeCorner, +} from "../../lib/cropOverlay"; +import { ACCENT, SPACE } from "../../lib/theme"; +import type { Clip, Crop } from "../../lib/types"; + +/** AppTheme.Spacing.smMd (CropOverlayView.swift:6). */ +const HANDLE_SIZE = SPACE.smMd; +/** AppTheme.Accent.timecodeColor (CropOverlayView.swift:7,9). */ +const BORDER_COLOR = ACCENT.timecode; +/** black @ AppTheme.Opacity.strong (CropOverlayView.swift:8). */ +const DIM_COLOR = "rgba(0,0,0,0.55)"; +/** AppTheme.Accent.timecodeColor @ AppTheme.Opacity.medium (CropOverlayView.swift:9). */ +const GUIDE_COLOR = "rgba(242,153,51,0.35)"; +/** AppTheme.BorderWidth.thin (CropOverlayView.swift:37, thirds guides). */ +const GUIDE_WIDTH = 1; +/** AppTheme.BorderWidth.medium (CropOverlayView.swift:38, crop rect border). */ +const BORDER_WIDTH = 2; + +const CORNERS: CropResizeCorner[] = ["topLeft", "topRight", "bottomLeft", "bottomRight"]; + +/** Corner handle position as a fraction (0 or 1) of the CROP rect's own + * width/height — resolved to px against `rect` (not the outer clip box) at + * render time, since the crop rect is a sub-region of the clip box + * (CropOverlayView.swift:247-254's `cornerPosition(_:in:)`, `in: cropRect`). */ +const CORNER_FRACTION: Record = { + topLeft: { x: 0, y: 0 }, + topRight: { x: 1, y: 0 }, + bottomLeft: { x: 0, y: 1 }, + bottomRight: { x: 1, y: 1 }, +}; + +const CORNER_CURSOR: Record = { + topLeft: "nwse-resize", + bottomRight: "nwse-resize", + topRight: "nesw-resize", + bottomLeft: "nesw-resize", +}; + +function cropRectPx(crop: Crop, clipRectPx: { width: number; height: number }) { + const visW = Math.max(0, 1 - crop.left - crop.right); + const visH = Math.max(0, 1 - crop.top - crop.bottom); + return { + left: crop.left * clipRectPx.width, + top: crop.top * clipRectPx.height, + width: visW * clipRectPx.width, + height: visH * clipRectPx.height, + }; +} + +export function CropOverlay({ + clip, + canvasPx, + sourcePixelAspect, +}: { + clip: Clip; + canvasPx: { width: number; height: number }; + /** Raw source pixel aspect (sourceWidth / sourceHeight), distinct from the + * timeline-canvas-normalized `mediaCanvasAspect` — 1:1 with upstream + * `sourcePixelAspect(for:)` (CropOverlayView.swift:207-212). */ + sourcePixelAspect: number | null; +}) { + const activeFrame = useEditorUiStore((s) => s.activeFrame); + const cropAspectLock = useEditorUiStore((s) => s.cropAspectLock); + + // Live-sampled rest transform/crop (matches upstream `clip.transformAt(frame:)` + // / `clip.cropAt(frame:)`) — follows keyframed tracks so the overlay always + // aligns with the rendered frame (CropOverlayView.swift:16-19). + const restTransform = sampledTransform(clip, activeFrame); + const restCrop = cropAt(clip, activeFrame); + const [dragCrop, setDragCrop] = useState(null); + const dragCleanupRef = useRef<(() => void) | null>(null); + + const cropAnimated = !!clip.cropTrack && clip.cropTrack.keyframes.length > 0; + + useEffect(() => { + setDragCrop(null); + }, [clip.id]); + + useEffect(() => { + return () => { + dragCleanupRef.current?.(); + dragCleanupRef.current = null; + }; + }, []); + + const display = dragCrop ?? restCrop; + + const commitCrop = (next: Crop) => { + if (cropAnimated) { + void edit.upsertKeyframe(clip.id, "crop", activeFrame, { kind: "crop", value: next }); + } else { + void edit.setClipProperties([clip.id], { crop: next }); + } + }; + + // Shared drag scaffolding, mirroring `TransformOverlay`'s `beginDrag`: + // registers window pointermove/up, feeds each move's pixel delta through + // `computeNext` for live local preview, and commits once on release. + const beginDrag = (e: React.PointerEvent, computeNext: (dxPx: number, dyPx: number) => Crop) => { + e.stopPropagation(); + e.preventDefault(); + const startClientX = e.clientX; + const startClientY = e.clientY; + const onMove = (ev: PointerEvent) => { + setDragCrop(computeNext(ev.clientX - startClientX, ev.clientY - startClientY)); + }; + const onUp = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + dragCleanupRef.current = null; + setDragCrop((cur) => { + if (cur) commitCrop(cur); + return null; + }); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + dragCleanupRef.current = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + }; + + const clipRectPx = { width: restTransform.width * canvasPx.width, height: restTransform.height * canvasPx.height }; + + const handlePanDown = (e: React.PointerEvent) => { + const start = restCrop; + beginDrag(e, (dx, dy) => { + const local = rotateDeltaIntoLocalFrame({ width: dx, height: dy }, restTransform.rotation); + return pannedCrop(start, local, clipRectPx); + }); + }; + + const handleResizeDown = (e: React.PointerEvent, corner: CropResizeCorner) => { + const start = restCrop; + const targetPixelAspect = cropAspectLockPixelAspect(cropAspectLock); + const aspectN = lockedAspectNormalized(targetPixelAspect, sourcePixelAspect); + beginDrag(e, (dx, dy) => { + const local = rotateDeltaIntoLocalFrame({ width: dx, height: dy }, restTransform.rotation); + return resizedCrop(start, corner, local, clipRectPx, aspectN); + }); + }; + + if ( + !Number.isFinite(canvasPx.width) || + !Number.isFinite(canvasPx.height) || + canvasPx.width <= 0 || + canvasPx.height <= 0 + ) { + return null; + } + + const rect = cropRectPx(display, clipRectPx); + + return ( +
+ {/* Dim mask: 4 rects covering the clip box outside the crop rect + (CropOverlayView.swift:22-27). */} +
+
+
+
+ + {/* Rule-of-thirds guide lines (CropOverlayView.swift:29-37): 2 vertical + + 2 horizontal, at 1/3 and 2/3 of the crop rect. */} + {[1, 2].map((i) => { + const f = i / 3; + return ( +
+
+
+
+ ); + })} + + {/* Crop rect border (CropOverlayView.swift:38). */} +
+ + {/* Drag-inside-to-pan surface (CropOverlayView.swift:42-50). */} +
+ + {CORNERS.map((corner) => { + const frac = CORNER_FRACTION[corner]; + return ( +
handleResizeDown(e, corner)} + style={{ + position: "absolute", + left: rect.left + rect.width * frac.x, + top: rect.top + rect.height * frac.y, + width: HANDLE_SIZE, + height: HANDLE_SIZE, + marginLeft: -HANDLE_SIZE / 2, + marginTop: -HANDLE_SIZE / 2, + background: BORDER_COLOR, + cursor: CORNER_CURSOR[corner], + pointerEvents: "auto", + }} + /> + ); + })} +
+ ); +} diff --git a/web/src/components/preview/Preview.tsx b/web/src/components/preview/Preview.tsx index f74dbf1..b5bac3c 100644 --- a/web/src/components/preview/Preview.tsx +++ b/web/src/components/preview/Preview.tsx @@ -25,6 +25,7 @@ import { maybeSnapFeedback } from "../../lib/haptic"; import { assetUrl } from "../../lib/asset"; import { TimelinePlayback } from "./TimelinePlaybackLayer"; import { TransformOverlay } from "./TransformOverlay"; +import { CropOverlay } from "./CropOverlay"; import { aspectFitBox, timelinePreviewCanvasStyle } from "./previewLayerStyles"; import { useT } from "../../i18n"; import { @@ -36,7 +37,7 @@ import { } from "../../lib/api"; import { rustEngineEnabled } from "./rustEngine"; import { shouldUseRustEngine } from "./timelinePlayback"; -import { findSelectedVisualClip, mediaCanvasAspect } from "../../lib/clip"; +import { findCropEditingClip, findSelectedVisualClip, mediaCanvasAspect } from "../../lib/clip"; import type { MediaItem } from "../../lib/types"; export function Preview() { @@ -70,6 +71,26 @@ export function Preview() { timeline.height, ); + // The Crop overlay's target clip (T3-11). Mutually exclusive with the + // Transform overlay — `PreviewContainerView.swift:37-41`'s + // `if cropEditingActive { CropOverlayView() } else { TransformOverlayView() }` + // — gated additionally on `cropEditingActive` at render time below. + // `findCropEditingClip` (unlike `findSelectedVisualClip`) excludes text + // clips and hides on an ambiguous match, per `CropOverlayView.selectedClip`'s + // exact rule (clip.ts doc comment). + const cropEditingActive = useEditorUiStore((s) => s.cropEditingActive); + const cropClip = cropEditingActive ? findCropEditingClip(timeline, selectedClipIds) : null; + const cropMediaItem = useMediaStore((s) => + cropClip ? s.items.find((m) => m.id === cropClip.mediaRef) ?? null : null, + ); + // Raw SOURCE pixel aspect (sourceWidth / sourceHeight) — distinct from + // `mediaCanvasAspect` above (which normalizes against the timeline canvas). + // 1:1 with upstream `sourcePixelAspect(for:)` (CropOverlayView.swift:207-212). + const cropSourcePixelAspect = + cropMediaItem?.width && cropMediaItem?.height && cropMediaItem.height > 0 + ? cropMediaItem.width / cropMediaItem.height + : null; + // Media-preview playback is driven by the app transport (more capable than the //