crop effect
This commit is contained in:
@@ -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<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState<DragHandle>(null);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [initialCrop, setInitialCrop] = useState<CropRegion>(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 (
|
||||
<div className="w-full p-8">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full aspect-video bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full rounded-lg"
|
||||
style={{ imageRendering: 'auto' }}
|
||||
/>
|
||||
|
||||
{/* Dark overlay outside crop */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<svg width="100%" height="100%" className="absolute inset-0">
|
||||
<defs>
|
||||
<mask id="cropMask">
|
||||
<rect width="100%" height="100%" fill="white" />
|
||||
<rect
|
||||
x={`${cropPixelX}%`}
|
||||
y={`${cropPixelY}%`}
|
||||
width={`${cropPixelWidth}%`}
|
||||
height={`${cropPixelHeight}%`}
|
||||
fill="black"
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="black" fillOpacity="0.6" mask="url(#cropMask)" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Crop region outline */}
|
||||
<div
|
||||
className="absolute border-2 border-white shadow-2xl pointer-events-none transition-none"
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
width: `${cropPixelWidth}%`,
|
||||
height: `${cropPixelHeight}%`,
|
||||
willChange: 'left, top, width, height',
|
||||
}}
|
||||
>
|
||||
{/* Grid lines */}
|
||||
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3 pointer-events-none">
|
||||
{[...Array(9)].map((_, i) => (
|
||||
<div key={i} className="border border-white/20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side handles */}
|
||||
{/* Top handle */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-20 h-1 bg-white/90 cursor-ns-resize z-20 pointer-events-auto shadow-md",
|
||||
"hover:bg-white hover:h-1.5 transition-all",
|
||||
isDragging === 'top' && "bg-white h-2"
|
||||
)}
|
||||
style={{
|
||||
left: `${cropPixelX + cropPixelWidth / 2}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, 'top')}
|
||||
/>
|
||||
|
||||
{/* Bottom handle */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-20 h-1 bg-white/90 cursor-ns-resize z-20 pointer-events-auto shadow-md",
|
||||
"hover:bg-white hover:h-1.5 transition-all",
|
||||
isDragging === 'bottom' && "bg-white h-2"
|
||||
)}
|
||||
style={{
|
||||
left: `${cropPixelX + cropPixelWidth / 2}%`,
|
||||
top: `${cropPixelY + cropPixelHeight}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, 'bottom')}
|
||||
/>
|
||||
|
||||
{/* Left handle */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-1 h-20 bg-white/90 cursor-ew-resize z-20 pointer-events-auto shadow-md",
|
||||
"hover:bg-white hover:w-1.5 transition-all",
|
||||
isDragging === 'left' && "bg-white w-2"
|
||||
)}
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY + cropPixelHeight / 2}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, 'left')}
|
||||
/>
|
||||
|
||||
{/* Right handle */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-1 h-20 bg-white/90 cursor-ew-resize z-20 pointer-events-auto shadow-md",
|
||||
"hover:bg-white hover:w-1.5 transition-all",
|
||||
isDragging === 'right' && "bg-white w-2"
|
||||
)}
|
||||
style={{
|
||||
left: `${cropPixelX + cropPixelWidth}%`,
|
||||
top: `${cropPixelY + cropPixelHeight / 2}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, 'right')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string>(GRADIENTS[0]);
|
||||
const [showCropDropdown, setShowCropDropdown] = useState(false);
|
||||
|
||||
const zoomEnabled = Boolean(selectedZoomDepth);
|
||||
|
||||
@@ -107,14 +114,71 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Switch
|
||||
checked={showShadow}
|
||||
onCheckedChange={onShadowChange}
|
||||
/>
|
||||
<div className="text-sm">Shadow</div>
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={showShadow}
|
||||
onCheckedChange={onShadowChange}
|
||||
/>
|
||||
<div className="text-sm">Shadow</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={showBlur}
|
||||
onCheckedChange={onBlurChange}
|
||||
/>
|
||||
<div className="text-sm">Blur Background</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
onClick={() => setShowCropDropdown(!showCropDropdown)}
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<Crop className="w-4 h-4" />
|
||||
Crop Video
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCropDropdown && cropRegion && onCropChange && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-40 animate-in fade-in duration-200"
|
||||
onClick={() => setShowCropDropdown(false)}
|
||||
/>
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 bg-card rounded-2xl shadow-2xl border border-border/50 p-8 w-[90vw] max-w-5xl animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<span className="text-xl font-bold text-foreground">Crop Video</span>
|
||||
<p className="text-sm text-muted-foreground mt-2">Drag the white handles on each side to adjust the crop area. Changes apply to the entire video.</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowCropDropdown(false)}
|
||||
className="hover:bg-muted"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<CropControl
|
||||
videoElement={videoElement || null}
|
||||
cropRegion={cropRegion}
|
||||
onCropChange={onCropChange}
|
||||
/>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCropDropdown(false)}
|
||||
size="lg"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Tabs defaultValue="image" className="mb-6">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="image">Image</TabsTrigger>
|
||||
@@ -171,6 +235,16 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="mt-auto pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full py-5 text-lg flex items-center justify-center gap-3 bg-primary text-white rounded-xl shadow-lg hover:bg-primary/90 transition-all"
|
||||
>
|
||||
<Download className="w-6 h-6" />
|
||||
<span className="text-lg">Export Video</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string>(WALLPAPER_PATHS[0]);
|
||||
const [showShadow, setShowShadow] = useState(false);
|
||||
const [showBlur, setShowBlur] = useState(false);
|
||||
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
|
||||
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
|
||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
||||
|
||||
@@ -187,6 +191,8 @@ export default function VideoEditor() {
|
||||
onZoomFocusChange={handleZoomFocusChange}
|
||||
isPlaying={isPlaying}
|
||||
showShadow={showShadow}
|
||||
showBlur={showBlur}
|
||||
cropRegion={cropRegion}
|
||||
/>
|
||||
</div>
|
||||
<PlaybackControls
|
||||
@@ -220,6 +226,11 @@ export default function VideoEditor() {
|
||||
onZoomDelete={handleZoomDelete}
|
||||
showShadow={showShadow}
|
||||
onShadowChange={setShowShadow}
|
||||
showBlur={showBlur}
|
||||
onBlurChange={setShowBlur}
|
||||
cropRegion={cropRegion}
|
||||
onCropChange={setCropRegion}
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
onZoomFocusChange,
|
||||
isPlaying,
|
||||
showShadow,
|
||||
showBlur,
|
||||
cropRegion,
|
||||
}, ref) => {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -67,6 +71,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
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<PIXI.Graphics | null>(null);
|
||||
const isPlayingRef = useRef(isPlaying);
|
||||
const isSeekingRef = useRef(false);
|
||||
@@ -118,6 +123,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
videoSprite,
|
||||
maskGraphics,
|
||||
videoElement,
|
||||
cropRegion,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
@@ -125,6 +131,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
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<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
|
||||
updateOverlayForRegion(activeRegion);
|
||||
}
|
||||
}, [updateOverlayForRegion]);
|
||||
}, [updateOverlayForRegion, cropRegion]);
|
||||
|
||||
const selectedZoom = useMemo(() => {
|
||||
if (!selectedZoomId) return null;
|
||||
@@ -232,7 +239,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
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<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
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<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
: { background: wallpaper || '/wallpapers/wallpaper1.jpg' };
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative aspect-video rounded-sm overflow-hidden bg-cover bg-center"
|
||||
style={{ ...backgroundStyle, width: '100%' }}
|
||||
>
|
||||
<div className="relative aspect-video rounded-sm overflow-hidden" style={{ width: '100%' }}>
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{
|
||||
...backgroundStyle,
|
||||
filter: showBlur ? 'blur(2px)' : 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0"
|
||||
|
||||
@@ -13,6 +13,20 @@ export interface ZoomRegion {
|
||||
focus: ZoomFocus;
|
||||
}
|
||||
|
||||
export interface CropRegion {
|
||||
x: number; // 0-1 normalized
|
||||
y: number; // 0-1 normalized
|
||||
width: number; // 0-1 normalized
|
||||
height: number; // 0-1 normalized
|
||||
}
|
||||
|
||||
export const DEFAULT_CROP_REGION: CropRegion = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
|
||||
1: 1.25,
|
||||
2: 1.5,
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user