diff --git a/src/components/video-editor/CropControl.tsx b/src/components/video-editor/CropControl.tsx new file mode 100644 index 0000000..ea003ea --- /dev/null +++ b/src/components/video-editor/CropControl.tsx @@ -0,0 +1,248 @@ +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; + +interface CropRegion { + x: number; // 0-1 normalized + y: number; // 0-1 normalized + width: number; // 0-1 normalized + height: number; // 0-1 normalized +} + +interface CropControlProps { + videoElement: HTMLVideoElement | null; + cropRegion: CropRegion; + onCropChange: (region: CropRegion) => void; +} + +type DragHandle = 'top' | 'right' | 'bottom' | 'left' | null; + +export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState(null); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [initialCrop, setInitialCrop] = useState(cropRegion); + + // Draw video preview at high quality + useEffect(() => { + if (!videoElement || !canvasRef.current) return; + + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d', { alpha: false }); + if (!ctx) return; + + // Set canvas to actual video dimensions for high quality + canvas.width = videoElement.videoWidth || 1920; + canvas.height = videoElement.videoHeight || 1080; + + const draw = () => { + if (videoElement.readyState >= 2) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height); + } + requestAnimationFrame(draw); + }; + + const rafId = requestAnimationFrame(draw); + return () => cancelAnimationFrame(rafId); + }, [videoElement]); + + const getContainerRect = () => { + return containerRef.current?.getBoundingClientRect() || { width: 0, height: 0, left: 0, top: 0 }; + }; + + const handlePointerDown = (e: React.PointerEvent, handle: DragHandle) => { + e.stopPropagation(); + e.preventDefault(); + setIsDragging(handle); + const rect = getContainerRect(); + setDragStart({ + x: (e.clientX - rect.left) / rect.width, + y: (e.clientY - rect.top) / rect.height, + }); + setInitialCrop(cropRegion); + + // Capture pointer for smooth dragging + e.currentTarget.setPointerCapture(e.pointerId); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!isDragging) return; + + const rect = getContainerRect(); + const currentX = (e.clientX - rect.left) / rect.width; + const currentY = (e.clientY - rect.top) / rect.height; + const deltaX = currentX - dragStart.x; + const deltaY = currentY - dragStart.y; + + let newCrop = { ...initialCrop }; + + switch (isDragging) { + case 'top': { + // Calculate new y position + const newY = Math.max(0, initialCrop.y + deltaY); + // Calculate the bottom edge (which should stay fixed) + const bottom = initialCrop.y + initialCrop.height; + // Ensure minimum height of 0.1 + newCrop.y = Math.min(newY, bottom - 0.1); + newCrop.height = bottom - newCrop.y; + break; + } + case 'bottom': + newCrop.height = Math.max(0.1, Math.min(initialCrop.height + deltaY, 1 - initialCrop.y)); + break; + case 'left': { + // Calculate new x position + const newX = Math.max(0, initialCrop.x + deltaX); + // Calculate the right edge (which should stay fixed) + const right = initialCrop.x + initialCrop.width; + // Ensure minimum width of 0.1 + newCrop.x = Math.min(newX, right - 0.1); + newCrop.width = right - newCrop.x; + break; + } + case 'right': + newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x)); + break; + } + + onCropChange(newCrop); + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (isDragging) { + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch { + // ignore + } + } + setIsDragging(null); + }; + + const cropPixelX = cropRegion.x * 100; + const cropPixelY = cropRegion.y * 100; + const cropPixelWidth = cropRegion.width * 100; + const cropPixelHeight = cropRegion.height * 100; + + return ( +
+
+ + + {/* Dark overlay outside crop */} +
+ + + + + + + + + +
+ + {/* Crop region outline */} +
+ {/* Grid lines */} +
+ {[...Array(9)].map((_, i) => ( +
+ ))} +
+
+ + {/* Side handles */} + {/* Top handle */} +
handlePointerDown(e, 'top')} + /> + + {/* Bottom handle */} +
handlePointerDown(e, 'bottom')} + /> + + {/* Left handle */} +
handlePointerDown(e, 'left')} + /> + + {/* Right handle */} +
handlePointerDown(e, 'right')} + /> +
+
+ ); +} diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index baf787e..58794c8 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -5,8 +5,9 @@ import { Button } from "@/components/ui/button"; import { useState } from "react"; import Colorful from '@uiw/react-color-colorful'; import { hsvaToHex } from '@uiw/color-convert'; -import { Trash2 } from "lucide-react"; -import type { ZoomDepth } from "./types"; +import { Trash2, Download, Crop, X } from "lucide-react"; +import type { ZoomDepth, CropRegion } from "./types"; +import { CropControl } from "./CropControl"; const WALLPAPER_COUNT = 12; const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`); @@ -34,6 +35,11 @@ interface SettingsPanelProps { onZoomDelete?: (id: string) => void; showShadow?: boolean; onShadowChange?: (showShadow: boolean) => void; + showBlur?: boolean; + onBlurChange?: (showBlur: boolean) => void; + cropRegion?: CropRegion; + onCropChange?: (region: CropRegion) => void; + videoElement?: HTMLVideoElement | null; } const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ @@ -44,9 +50,10 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 5, label: "3.5×" }, ]; -export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, showShadow, onShadowChange }: SettingsPanelProps) { +export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, showShadow, onShadowChange, showBlur, onBlurChange, cropRegion, onCropChange, videoElement }: SettingsPanelProps) { const [hsva, setHsva] = useState({ h: 0, s: 0, v: 68, a: 1 }); const [gradient, setGradient] = useState(GRADIENTS[0]); + const [showCropDropdown, setShowCropDropdown] = useState(false); const zoomEnabled = Boolean(selectedZoomDepth); @@ -107,14 +114,71 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, )}
-
- -
Shadow
+
+
+ +
Shadow
+
+
+ +
Blur Background
+
+
+ +
+ + {showCropDropdown && cropRegion && onCropChange && ( + <> +
setShowCropDropdown(false)} + /> +
+
+
+ Crop Video +

Drag the white handles on each side to adjust the crop area. Changes apply to the entire video.

+
+ +
+ +
+ +
+
+ + )} Image @@ -171,6 +235,16 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
+
+ +
); } diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 1aa376e..d85bff1 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -11,9 +11,11 @@ import type { Span } from "dnd-timeline"; import { DEFAULT_ZOOM_DEPTH, clampFocusToDepth, + DEFAULT_CROP_REGION, type ZoomDepth, type ZoomFocus, type ZoomRegion, + type CropRegion, } from "./types"; const WALLPAPER_COUNT = 12; @@ -28,6 +30,8 @@ export default function VideoEditor() { const [duration, setDuration] = useState(0); const [wallpaper, setWallpaper] = useState(WALLPAPER_PATHS[0]); const [showShadow, setShowShadow] = useState(false); + const [showBlur, setShowBlur] = useState(false); + const [cropRegion, setCropRegion] = useState(DEFAULT_CROP_REGION); const [zoomRegions, setZoomRegions] = useState([]); const [selectedZoomId, setSelectedZoomId] = useState(null); @@ -187,6 +191,8 @@ export default function VideoEditor() { onZoomFocusChange={handleZoomFocusChange} isPlaying={isPlaying} showShadow={showShadow} + showBlur={showBlur} + cropRegion={cropRegion} />
); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index d4a69e5..83252c5 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -24,6 +24,8 @@ interface VideoPlaybackProps { onZoomFocusChange: (id: string, focus: ZoomFocus) => void; isPlaying: boolean; showShadow?: boolean; + showBlur?: boolean; + cropRegion?: import('./types').CropRegion; } export interface VideoPlaybackRef { @@ -46,6 +48,8 @@ const VideoPlayback = forwardRef(({ onZoomFocusChange, isPlaying, showShadow, + showBlur, + cropRegion, }, ref) => { const videoRef = useRef(null); const containerRef = useRef(null); @@ -67,6 +71,7 @@ const VideoPlayback = forwardRef(({ const videoSizeRef = useRef({ width: 0, height: 0 }); const baseScaleRef = useRef(1); const baseOffsetRef = useRef({ x: 0, y: 0 }); + const baseMaskRef = useRef({ x: 0, y: 0, width: 0, height: 0 }); const maskGraphicsRef = useRef(null); const isPlayingRef = useRef(isPlaying); const isSeekingRef = useRef(false); @@ -118,6 +123,7 @@ const VideoPlayback = forwardRef(({ videoSprite, maskGraphics, videoElement, + cropRegion, }); if (result) { @@ -125,6 +131,7 @@ const VideoPlayback = forwardRef(({ videoSizeRef.current = result.videoSize; baseScaleRef.current = result.baseScale; baseOffsetRef.current = result.baseOffset; + baseMaskRef.current = result.maskRect; const selectedId = selectedZoomIdRef.current; const activeRegion = selectedId @@ -133,7 +140,7 @@ const VideoPlayback = forwardRef(({ updateOverlayForRegion(activeRegion); } - }, [updateOverlayForRegion]); + }, [updateOverlayForRegion, cropRegion]); const selectedZoom = useMemo(() => { if (!selectedZoomId) return null; @@ -232,7 +239,7 @@ const VideoPlayback = forwardRef(({ useEffect(() => { if (!pixiReady || !videoReady) return; layoutVideoContent(); - }, [pixiReady, videoReady, layoutVideoContent]); + }, [pixiReady, videoReady, layoutVideoContent, cropRegion]); useEffect(() => { if (!pixiReady || !videoReady) return; @@ -433,6 +440,7 @@ const VideoPlayback = forwardRef(({ videoSize: videoSizeRef.current, baseScale: baseScaleRef.current, baseOffset: baseOffsetRef.current, + baseMask: baseMaskRef.current, zoomScale: state.scale, focusX: state.focusX, focusY: state.focusY, @@ -526,10 +534,14 @@ const VideoPlayback = forwardRef(({ : { background: wallpaper || '/wallpapers/wallpaper1.jpg' }; return ( -
+
+
= { 1: 1.25, 2: 1.5, diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index 0caef3f..a7c2582 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -1,5 +1,6 @@ import * as PIXI from 'pixi.js'; import { VIEWPORT_SCALE } from "./constants"; +import type { CropRegion } from '../types'; interface LayoutParams { container: HTMLDivElement; @@ -7,6 +8,7 @@ interface LayoutParams { videoSprite: PIXI.Sprite; maskGraphics: PIXI.Graphics; videoElement: HTMLVideoElement; + cropRegion?: CropRegion; } interface LayoutResult { @@ -14,10 +16,11 @@ interface LayoutResult { videoSize: { width: number; height: number }; baseScale: number; baseOffset: { x: number; y: number }; + maskRect: { x: number; y: number; width: number; height: number }; } export function layoutVideoContent(params: LayoutParams): LayoutResult | null { - const { container, app, videoSprite, maskGraphics, videoElement } = params; + const { container, app, videoSprite, maskGraphics, videoElement, cropRegion } = params; const videoWidth = videoElement.videoWidth; const videoHeight = videoElement.videoHeight; @@ -37,32 +40,60 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { app.canvas.style.width = '100%'; app.canvas.style.height = '100%'; + // Apply crop region + const crop = cropRegion || { x: 0, y: 0, width: 1, height: 1 }; + + // Calculate the cropped dimensions + const croppedVideoWidth = videoWidth * crop.width; + const croppedVideoHeight = videoHeight * crop.height; + + // Calculate scale to fit the cropped area in the viewport const maxDisplayWidth = width * VIEWPORT_SCALE; const maxDisplayHeight = height * VIEWPORT_SCALE; const scale = Math.min( - maxDisplayWidth / videoWidth, - maxDisplayHeight / videoHeight, + maxDisplayWidth / croppedVideoWidth, + maxDisplayHeight / croppedVideoHeight, 1 ); videoSprite.scale.set(scale); - const displayWidth = videoWidth * scale; - const displayHeight = videoHeight * scale; + + // Calculate display size of the full video at this scale + const fullVideoDisplayWidth = videoWidth * scale; + const fullVideoDisplayHeight = videoHeight * scale; + + // Calculate display size of just the cropped region + const croppedDisplayWidth = croppedVideoWidth * scale; + const croppedDisplayHeight = croppedVideoHeight * scale; - const offsetX = (width - displayWidth) / 2; - const offsetY = (height - displayHeight) / 2; - videoSprite.position.set(offsetX, offsetY); + // Center the cropped region in the container + const centerOffsetX = (width - croppedDisplayWidth) / 2; + const centerOffsetY = (height - croppedDisplayHeight) / 2; + + // Position the full video sprite so that when we apply the mask, + // the cropped region appears centered + // The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates + // In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight) + // We want that point to be at centerOffsetX, centerOffsetY + const spriteX = centerOffsetX - (crop.x * fullVideoDisplayWidth); + const spriteY = centerOffsetY - (crop.y * fullVideoDisplayHeight); + + videoSprite.position.set(spriteX, spriteY); - const radius = Math.min(displayWidth, displayHeight) * 0.02; + // Create a mask that only shows the cropped region (centered in container) + const maskX = centerOffsetX; + const maskY = centerOffsetY; + const radius = Math.min(croppedDisplayWidth, croppedDisplayHeight) * 0.02; maskGraphics.clear(); - maskGraphics.roundRect(offsetX, offsetY, displayWidth, displayHeight, radius); + maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, radius); maskGraphics.fill({ color: 0xffffff }); return { stageSize: { width, height }, - videoSize: { width: videoWidth, height: videoHeight }, + videoSize: { width: croppedVideoWidth, height: croppedVideoHeight }, baseScale: scale, - baseOffset: { x: offsetX, y: offsetY }, + baseOffset: { x: spriteX, y: spriteY }, + maskRect: { x: maskX, y: maskY, width: croppedDisplayWidth, height: croppedDisplayHeight }, }; } diff --git a/src/components/video-editor/videoPlayback/zoomTransform.ts b/src/components/video-editor/videoPlayback/zoomTransform.ts index dce5fa3..0147bd0 100644 --- a/src/components/video-editor/videoPlayback/zoomTransform.ts +++ b/src/components/video-editor/videoPlayback/zoomTransform.ts @@ -8,6 +8,7 @@ interface TransformParams { videoSize: { width: number; height: number }; baseScale: number; baseOffset: { x: number; y: number }; + baseMask: { x: number; y: number; width: number; height: number }; zoomScale: number; focusX: number; focusY: number; @@ -24,6 +25,7 @@ export function applyZoomTransform(params: TransformParams) { videoSize, baseScale, baseOffset, + baseMask, zoomScale, focusX, focusY, @@ -31,7 +33,15 @@ export function applyZoomTransform(params: TransformParams) { isPlaying, } = params; - if (!stageSize.width || !stageSize.height || !videoSize.width || !videoSize.height || baseScale <= 0) { + if ( + !stageSize.width || + !stageSize.height || + !videoSize.width || + !videoSize.height || + baseScale <= 0 || + baseMask.width <= 0 || + baseMask.height <= 0 + ) { return; } @@ -46,6 +56,8 @@ export function applyZoomTransform(params: TransformParams) { // Keep the focus point centered in viewport after zoom transformation const baseVideoX = baseOffset.x; const baseVideoY = baseOffset.y; + const baseMaskX = baseMask.x; + const baseMaskY = baseMask.y; const focusInVideoSpaceX = focusStagePxX - baseVideoX; const focusInVideoSpaceY = focusStagePxY - baseVideoY; @@ -61,10 +73,12 @@ export function applyZoomTransform(params: TransformParams) { blurFilter.blur = motionBlur; } - const videoWidth = videoSize.width * actualScale; - const videoHeight = videoSize.height * actualScale; - const radius = Math.min(videoWidth, videoHeight) * 0.02; + const maskWidth = baseMask.width * zoomScale; + const maskHeight = baseMask.height * zoomScale; + const maskX = baseMaskX + (newVideoX - baseVideoX); + const maskY = baseMaskY + (newVideoY - baseVideoY); + const radius = Math.min(maskWidth, maskHeight) * 0.02; maskGraphics.clear(); - maskGraphics.roundRect(newVideoX, newVideoY, videoWidth, videoHeight, radius); + maskGraphics.roundRect(maskX, maskY, maskWidth, maskHeight, radius); maskGraphics.fill({ color: 0xffffff }); }