Compare commits

...
Author SHA1 Message Date
Siddharth 0c89e3e01a export aspect ratio 2025-11-29 11:38:09 -07:00
Siddharth d2ee511466 preview aspect ratio 2025-11-28 23:54:58 -07:00
Siddharth 4c725dfceb settings cleanup 2025-11-28 21:55:42 -07:00
Siddharth 71e2b51f5b padding video control 2025-11-28 21:46:05 -07:00
Siddharth c9321240d8 enable custom border radius 2025-11-28 19:15:56 -07:00
Siddharth 159f770da8 missing item depth level 2025-11-28 18:48:11 -07:00
Siddharth 443e4b0581 motion blur export stale closure 2025-11-28 18:04:38 -07:00
Siddharth 65bc21f153 fix the bug i just introduced lol 2025-11-28 17:41:30 -07:00
Siddharth 59807662d8 aspect ratio bug 2025-11-28 17:14:55 -07:00
Siddharth 8f31bde518 motion-blue switch 2025-11-28 16:08:11 -07:00
Sid 6fb9a24834 Merge pull request #14 from siddharthvaddem/feature/trim
Feature/trim
2025-11-27 22:31:33 -08:00
17 changed files with 293 additions and 53 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 890 KiB

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

+14 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils";
interface CropRegion {
x: number; // 0-1 normalized
@@ -12,11 +13,12 @@ interface CropControlProps {
videoElement: HTMLVideoElement | null;
cropRegion: CropRegion;
onCropChange: (region: CropRegion) => void;
aspectRatio: AspectRatio;
}
type DragHandle = 'top' | 'right' | 'bottom' | 'left' | null;
export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) {
export function CropControl({ videoElement, cropRegion, onCropChange, aspectRatio }: CropControlProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState<DragHandle>(null);
@@ -114,12 +116,22 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
const cropPixelY = cropRegion.y * 100;
const cropPixelWidth = cropRegion.width * 100;
const cropPixelHeight = cropRegion.height * 100;
const videoAspectRatio = videoElement ? videoElement.videoWidth / videoElement.videoHeight : 16/9;
const isVideoPortrait = videoAspectRatio < 1;
const maxContainerWidth = isVideoPortrait ? '40vw' : '75vw';
const maxContainerHeight = '75vh';
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"
className="relative w-full bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl"
style={{
aspectRatio: videoAspectRatio,
maxWidth: maxContainerWidth,
maxHeight: maxContainerHeight,
margin: '0 auto',
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
@@ -15,6 +15,10 @@ export function KeyboardShortcutsHelp() {
<span className="text-slate-400">Add Keyframe</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">F</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Add Trim</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">T</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Delete Selected</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⌘ + D</kbd>
+63 -10
View File
@@ -14,8 +14,9 @@ import { toast } from "sonner";
import type { ZoomDepth, CropRegion } from "./types";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
const WALLPAPER_COUNT = 20;
const WALLPAPER_COUNT = 18;
const WALLPAPER_RELATIVE = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `wallpapers/wallpaper${i + 1}.jpg`);
const GRADIENTS = [
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
@@ -55,8 +56,15 @@ interface SettingsPanelProps {
onShadowChange?: (intensity: number) => void;
showBlur?: boolean;
onBlurChange?: (showBlur: boolean) => void;
motionBlurEnabled?: boolean;
onMotionBlurChange?: (enabled: boolean) => void;
borderRadius?: number;
onBorderRadiusChange?: (radius: number) => void;
padding?: number;
onPaddingChange?: (padding: number) => void;
cropRegion?: CropRegion;
onCropChange?: (region: CropRegion) => void;
aspectRatio: AspectRatio;
videoElement?: HTMLVideoElement | null;
onExport?: () => void;
}
@@ -72,7 +80,7 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
{ depth: 6, label: "5×" },
];
export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, shadowIntensity = 0, onShadowChange, showBlur, onBlurChange, cropRegion, onCropChange, videoElement, onExport }: SettingsPanelProps) {
export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, shadowIntensity = 0, onShadowChange, showBlur, onBlurChange, motionBlurEnabled = true, onMotionBlurChange, borderRadius = 0, onBorderRadiusChange, padding = 50, onPaddingChange, cropRegion, onCropChange, aspectRatio, videoElement, onExport }: SettingsPanelProps) {
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
const [customImages, setCustomImages] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -201,6 +209,29 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
)}
</div>
<div className="mb-6">
<div className="grid grid-cols-2 gap-3">
{/* Motion Blur Switch */}
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
<div className="text-xs font-medium text-slate-200">Motion Blur</div>
<Switch
checked={motionBlurEnabled}
onCheckedChange={onMotionBlurChange}
className="data-[state=checked]:bg-[#34B27B]"
/>
</div>
{/* Blur Background Switch */}
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
<div className="text-xs font-medium text-slate-200">Blur</div>
<Switch
checked={showBlur}
onCheckedChange={onBlurChange}
className="data-[state=checked]:bg-[#34B27B]"
/>
</div>
</div>
</div>
<div className="mb-6">
<div className="grid grid-cols-2 gap-3">
{/* Drop Shadow Slider */}
@@ -218,13 +249,34 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B]"
/>
</div>
{/* Blur Background Switch */}
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
<div className="text-xs font-medium text-slate-200">Blur</div>
<Switch
checked={showBlur}
onCheckedChange={onBlurChange}
className="data-[state=checked]:bg-[#34B27B]"
{/* Corner Roundness Slider */}
<div className="p-3 rounded-xl bg-white/5 border border-white/5 space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-slate-200">Roundness</div>
<span className="text-[10px] text-slate-400 font-mono">{borderRadius}px</span>
</div>
<Slider
value={[borderRadius]}
onValueChange={(values) => onBorderRadiusChange?.(values[0])}
min={0}
max={16}
step={0.5}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B]"
/>
</div>
{/* Padding Slider */}
<div className="p-3 rounded-xl bg-white/5 border border-white/5 space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-slate-200">Padding</div>
<span className="text-[10px] text-slate-400 font-mono">{padding}%</span>
</div>
<Slider
value={[padding]}
onValueChange={(values) => onPaddingChange?.(values[0])}
min={0}
max={100}
step={1}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B]"
/>
</div>
</div>
@@ -250,7 +302,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 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-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-5xl animate-in zoom-in-95 duration-200">
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-5xl max-h-[90vh] overflow-auto animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between mb-6">
<div>
<span className="text-xl font-bold text-slate-200">Crop Video</span>
@@ -269,6 +321,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
videoElement={videoElement || null}
cropRegion={cropRegion}
onCropChange={onCropChange}
aspectRatio={aspectRatio}
/>
<div className="mt-6 flex justify-end">
<Button
+91 -25
View File
@@ -23,8 +23,9 @@ import {
type CropRegion,
} from "./types";
import { VideoExporter, type ExportProgress } from "@/lib/exporter";
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
const WALLPAPER_COUNT = 20;
const WALLPAPER_COUNT = 18;
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
export default function VideoEditor() {
@@ -37,6 +38,9 @@ export default function VideoEditor() {
const [wallpaper, setWallpaper] = useState<string>(WALLPAPER_PATHS[0]);
const [shadowIntensity, setShadowIntensity] = useState(0);
const [showBlur, setShowBlur] = useState(false);
const [motionBlurEnabled, setMotionBlurEnabled] = useState(true);
const [borderRadius, setBorderRadius] = useState(0);
const [padding, setPadding] = useState(50);
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
@@ -46,6 +50,7 @@ export default function VideoEditor() {
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('16:9');
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
@@ -260,11 +265,56 @@ export default function VideoEditor() {
return;
}
const width = video.videoWidth || 1920;
const height = video.videoHeight || 1080;
const aspectRatioValue = getAspectRatioValue(aspectRatio);
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
let exportWidth: number = sourceWidth;
let exportHeight: number = sourceHeight;
if (aspectRatioValue === 1) {
// Square (1:1): use smaller dimension to avoid codec limits
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
exportWidth = baseDimension;
exportHeight = baseDimension;
} else if (aspectRatioValue > 1) {
// Landscape: find largest even dimensions that exactly match aspect ratio
const baseWidth = Math.floor(sourceWidth / 2) * 2;
// Iterate down from baseWidth to find exact match
let found = false;
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
const h = Math.round(w / aspectRatioValue);
if (h % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportWidth = baseWidth;
exportHeight = Math.floor((baseWidth / aspectRatioValue) / 2) * 2;
}
} else {
// Portrait: find largest even dimensions that exactly match aspect ratio
const baseHeight = Math.floor(sourceHeight / 2) * 2;
// Iterate down from baseHeight to find exact match
let found = false;
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
const w = Math.round(h * aspectRatioValue);
if (w % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportHeight = baseHeight;
exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
}
}
// Calculate visually lossless bitrate matching screen recording optimization
const totalPixels = width * height;
const totalPixels = exportWidth * exportHeight;
let bitrate = 30_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
bitrate = 50_000_000;
@@ -274,8 +324,8 @@ export default function VideoEditor() {
const exporter = new VideoExporter({
videoUrl: videoPath,
width,
height,
width: exportWidth,
height: exportHeight,
frameRate: 60,
bitrate,
codec: 'avc1.640033',
@@ -285,6 +335,9 @@ export default function VideoEditor() {
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
@@ -326,7 +379,7 @@ export default function VideoEditor() {
setIsExporting(false);
exporterRef.current = null;
}
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, cropRegion, isPlaying]);
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, isPlaying, aspectRatio]);
const handleCancelExport = useCallback(() => {
if (exporterRef.current) {
@@ -373,8 +426,9 @@ export default function VideoEditor() {
<div className="w-full h-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden">
{/* Video preview */}
<div className="w-full flex justify-center items-center" style={{ flex: '1 1 auto', margin: '6px 0 0' }}>
<div className="relative" style={{ width: 'auto', height: '100%', aspectRatio: '16/9', maxWidth: '100%', margin: '0 auto', boxSizing: 'border-box' }}>
<div className="relative" style={{ width: 'auto', height: '100%', aspectRatio: getAspectRatioValue(aspectRatio), maxWidth: '100%', margin: '0 auto', boxSizing: 'border-box' }}>
<VideoPlayback
aspectRatio={aspectRatio}
ref={videoPlaybackRef}
videoPath={videoPath || ''}
onDurationChange={setDuration}
@@ -390,6 +444,9 @@ export default function VideoEditor() {
showShadow={shadowIntensity > 0}
shadowIntensity={shadowIntensity}
showBlur={showBlur}
motionBlurEnabled={motionBlurEnabled}
borderRadius={borderRadius}
padding={padding}
cropRegion={cropRegion}
trimRegions={trimRegions}
/>
@@ -418,29 +475,31 @@ export default function VideoEditor() {
<Panel defaultSize={30} minSize={20}>
<div className="h-full bg-[#09090b] rounded-2xl border border-white/5 shadow-lg overflow-hidden flex flex-col">
<TimelineEditor
videoDuration={duration}
currentTime={currentTime}
onSeek={handleSeek}
zoomRegions={zoomRegions}
onZoomAdded={handleZoomAdded}
onZoomSpanChange={handleZoomSpanChange}
onZoomDelete={handleZoomDelete}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
trimRegions={trimRegions}
onTrimAdded={handleTrimAdded}
onTrimSpanChange={handleTrimSpanChange}
onTrimDelete={handleTrimDelete}
selectedTrimId={selectedTrimId}
onSelectTrim={handleSelectTrim}
/>
videoDuration={duration}
currentTime={currentTime}
onSeek={handleSeek}
zoomRegions={zoomRegions}
onZoomAdded={handleZoomAdded}
onZoomSpanChange={handleZoomSpanChange}
onZoomDelete={handleZoomDelete}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
trimRegions={trimRegions}
onTrimAdded={handleTrimAdded}
onTrimSpanChange={handleTrimSpanChange}
onTrimDelete={handleTrimDelete}
selectedTrimId={selectedTrimId}
onSelectTrim={handleSelectTrim}
aspectRatio={aspectRatio}
onAspectRatioChange={setAspectRatio}
/>
</div>
</Panel>
</PanelGroup>
</div>
{/* Right section: settings panel */}
<SettingsPanel
<SettingsPanel
selected={wallpaper}
onWallpaperChange={setWallpaper}
selectedZoomDepth={selectedZoomId ? zoomRegions.find(z => z.id === selectedZoomId)?.depth : null}
@@ -451,8 +510,15 @@ export default function VideoEditor() {
onShadowChange={setShadowIntensity}
showBlur={showBlur}
onBlurChange={setShowBlur}
motionBlurEnabled={motionBlurEnabled}
onMotionBlurChange={setMotionBlurEnabled}
borderRadius={borderRadius}
onBorderRadiusChange={setBorderRadius}
padding={padding}
onPaddingChange={setPadding}
cropRegion={cropRegion}
onCropChange={setCropRegion}
aspectRatio={aspectRatio}
videoElement={videoPlaybackRef.current?.video || null}
onExport={handleExport}
/>
+20 -2
View File
@@ -11,6 +11,7 @@ import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { applyZoomTransform } from "./videoPlayback/zoomTransform";
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils";
interface VideoPlaybackProps {
videoPath: string;
@@ -27,8 +28,12 @@ interface VideoPlaybackProps {
showShadow?: boolean;
shadowIntensity?: number;
showBlur?: boolean;
motionBlurEnabled?: boolean;
borderRadius?: number;
padding?: number;
cropRegion?: import('./types').CropRegion;
trimRegions?: TrimRegion[];
aspectRatio: AspectRatio;
}
export interface VideoPlaybackRef {
@@ -55,8 +60,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
showShadow,
shadowIntensity = 0,
showBlur,
motionBlurEnabled = true,
borderRadius = 0,
padding = 50,
cropRegion,
trimRegions = [],
aspectRatio,
}, ref) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -88,6 +97,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
const layoutVideoContentRef = useRef<(() => void) | null>(null);
const trimRegionsRef = useRef<TrimRegion[]>([]);
const motionBlurEnabledRef = useRef(motionBlurEnabled);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
@@ -147,6 +157,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
videoElement,
cropRegion,
lockedVideoDimensions: lockedVideoDimensionsRef.current,
borderRadius,
padding,
});
if (result) {
@@ -168,7 +180,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
updateOverlayForRegion(activeRegion);
}
}, [updateOverlayForRegion, cropRegion]);
}, [updateOverlayForRegion, cropRegion, borderRadius, padding]);
useEffect(() => {
layoutVideoContentRef.current = layoutVideoContent;
@@ -294,6 +306,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
trimRegionsRef.current = trimRegions;
}, [trimRegions]);
useEffect(() => {
motionBlurEnabledRef.current = motionBlurEnabled;
}, [motionBlurEnabled]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
@@ -351,6 +367,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
focusY: DEFAULT_FOCUS.cy,
motionIntensity: 0,
isPlaying: false,
motionBlurEnabled: motionBlurEnabledRef.current,
});
requestAnimationFrame(() => {
@@ -585,6 +602,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
focusY: state.focusY,
motionIntensity,
isPlaying: isPlayingRef.current,
motionBlurEnabled: motionBlurEnabledRef.current,
});
};
@@ -732,7 +750,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
: { background: resolvedWallpaper || '' };
return (
<div className="relative aspect-video rounded-sm overflow-hidden" style={{ width: '100%' }}>
<div className="relative rounded-sm overflow-hidden" style={{ width: '100%', aspectRatio: formatAspectRatioForCSS(aspectRatio) }}>
{/* Background layer - always render as DOM element with blur */}
<div
className="absolute inset-0 bg-cover bg-center"
@@ -22,6 +22,7 @@ const ZOOM_LABELS: Record<number, string> = {
3: "1.8×",
4: "2.2×",
5: "3.5×",
6: "5×",
};
export default function Item({
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTimelineContext } from "dnd-timeline";
import { Button } from "@/components/ui/button";
import { Plus, Scissors, ZoomIn } from "lucide-react";
import { Plus, Scissors, ZoomIn, ChevronDown, Check } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import TimelineWrapper from "./TimelineWrapper";
@@ -11,6 +11,13 @@ import KeyframeMarkers from "./KeyframeMarkers";
import type { Range, Span } from "dnd-timeline";
import type { ZoomRegion, TrimRegion } from "../types";
import { v4 as uuidv4 } from 'uuid';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
@@ -34,6 +41,8 @@ interface TimelineEditorProps {
onTrimDelete?: (id: string) => void;
selectedTrimId?: string | null;
onSelectTrim?: (id: string | null) => void;
aspectRatio: AspectRatio;
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
}
interface TimelineScaleConfig {
@@ -410,6 +419,8 @@ export default function TimelineEditor({
onTrimDelete,
selectedTrimId,
onSelectTrim,
aspectRatio,
onAspectRatioChange,
}: TimelineEditorProps) {
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
@@ -683,6 +694,32 @@ export default function TimelineEditor({
<Scissors className="w-4 h-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1"
>
<span className="font-medium">{getAspectRatioLabel(aspectRatio)}</span>
<ChevronDown className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[#1a1a1a] border-white/10">
{(['16:9', '9:16', '1:1', '4:3', '4:5'] as AspectRatio[]).map((ratio) => (
<DropdownMenuItem
key={ratio}
onClick={() => onAspectRatioChange(ratio)}
className="text-slate-300 hover:text-white hover:bg-white/10 cursor-pointer flex items-center justify-between gap-3"
>
<span>{getAspectRatioLabel(ratio)}</span>
{aspectRatio === ratio && <Check className="w-3 h-3 text-[#34B27B]" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex-1" />
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
<span className="flex items-center gap-1.5">
@@ -10,6 +10,8 @@ interface LayoutParams {
videoElement: HTMLVideoElement;
cropRegion?: CropRegion;
lockedVideoDimensions?: { width: number; height: number } | null;
borderRadius?: number;
padding?: number;
}
interface LayoutResult {
@@ -22,8 +24,7 @@ interface LayoutResult {
}
export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
const { container, app, videoSprite, maskGraphics, videoElement, cropRegion, lockedVideoDimensions } = params;
const { container, app, videoSprite, maskGraphics, videoElement, cropRegion, lockedVideoDimensions, borderRadius = 0, padding = 0 } = params;
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
const videoHeight = lockedVideoDimensions?.height || videoElement.videoHeight;
@@ -56,8 +57,10 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
const cropEndY = cropStartY + croppedVideoHeight;
// Calculate scale to fit the cropped area in the viewport
const maxDisplayWidth = width * VIEWPORT_SCALE;
const maxDisplayHeight = height * VIEWPORT_SCALE;
// Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8
const paddingScale = 1.0 - (padding / 100) * 0.4;
const maxDisplayWidth = width * paddingScale;
const maxDisplayHeight = height * paddingScale;
const scale = Math.min(
maxDisplayWidth / croppedVideoWidth,
@@ -92,9 +95,10 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
// 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;
// Apply border radius
maskGraphics.clear();
maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, radius);
maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, borderRadius);
maskGraphics.fill({ color: 0xffffff });
return {
@@ -10,6 +10,7 @@ interface TransformParams {
focusY: number;
motionIntensity: number;
isPlaying: boolean;
motionBlurEnabled?: boolean;
}
export function applyZoomTransform({
@@ -22,6 +23,7 @@ export function applyZoomTransform({
focusY,
motionIntensity,
isPlaying,
motionBlurEnabled = true,
}: TransformParams) {
if (
stageSize.width <= 0 ||
@@ -52,7 +54,7 @@ export function applyZoomTransform({
cameraContainer.position.set(cameraX, cameraY);
if (blurFilter) {
const shouldBlur = isPlaying && motionIntensity > 0.0005;
const shouldBlur = motionBlurEnabled && isPlaying && motionIntensity > 0.0005;
const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0;
blurFilter.blur = motionBlur;
}
+11 -6
View File
@@ -14,6 +14,9 @@ interface FrameRenderConfig {
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled?: boolean;
borderRadius?: number;
padding?: number;
cropRegion: CropRegion;
videoWidth: number;
videoHeight: number;
@@ -286,6 +289,7 @@ export class FrameRenderer {
focusY: this.animationState.focusY,
motionIntensity: maxMotionIntensity,
isPlaying: true,
motionBlurEnabled: this.config.motionBlurEnabled ?? true,
});
// Render the PixiJS stage to its canvas (video only, transparent background)
@@ -299,7 +303,7 @@ export class FrameRenderer {
if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return;
const { width, height } = this.config;
const { cropRegion } = this.config;
const { cropRegion, borderRadius = 0, padding = 0 } = this.config;
const videoWidth = this.config.videoWidth;
const videoHeight = this.config.videoHeight;
@@ -311,10 +315,12 @@ export class FrameRenderer {
const croppedVideoWidth = videoWidth * (cropEndX - cropStartX);
const croppedVideoHeight = videoHeight * (cropEndY - cropStartY);
// Calculate scale to fit in viewport
const viewportWidth = width * VIEWPORT_SCALE;
const viewportHeight = height * VIEWPORT_SCALE;
// Padding is a percentage (0-100), where 50% ~ 0.8 scale
const paddingScale = 1.0 - (padding / 100) * 0.4;
const viewportWidth = width * paddingScale;
const viewportHeight = height * paddingScale;
const scale = Math.min(viewportWidth / croppedVideoWidth, viewportHeight / croppedVideoHeight);
// Position video sprite
@@ -335,9 +341,8 @@ export class FrameRenderer {
this.videoContainer.y = centerOffsetY;
// Update mask
const radius = Math.min(croppedDisplayWidth, croppedDisplayHeight) * 0.02;
this.maskGraphics.clear();
this.maskGraphics.roundRect(0, 0, croppedDisplayWidth, croppedDisplayHeight, radius);
this.maskGraphics.roundRect(0, 0, croppedDisplayWidth, croppedDisplayHeight, borderRadius);
this.maskGraphics.fill({ color: 0xffffff });
// Cache layout info
+7
View File
@@ -12,6 +12,10 @@ interface VideoExporterConfig extends ExportConfig {
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled?: boolean;
borderRadius?: number;
padding?: number;
videoPadding?: number;
cropRegion: CropRegion;
onProgress?: (progress: ExportProgress) => void;
}
@@ -84,6 +88,9 @@ export class VideoExporter {
showShadow: this.config.showShadow,
shadowIntensity: this.config.shadowIntensity,
showBlur: this.config.showBlur,
motionBlurEnabled: this.config.motionBlurEnabled,
borderRadius: this.config.borderRadius,
padding: this.config.padding,
cropRegion: this.config.cropRegion,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
+31
View File
@@ -0,0 +1,31 @@
export type AspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '4:5';
export function getAspectRatioValue(aspectRatio: AspectRatio): number {
switch (aspectRatio) {
case '16:9': return 16 / 9;
case '9:16': return 9 / 16;
case '1:1': return 1;
case '4:3': return 4 / 3;
case '4:5': return 4 / 5;
}
}
export function getAspectRatioDimensions(
aspectRatio: AspectRatio,
baseWidth: number
): { width: number; height: number } {
const ratio = getAspectRatioValue(aspectRatio);
return {
width: baseWidth,
height: baseWidth / ratio,
};
}
export function getAspectRatioLabel(aspectRatio: AspectRatio): string {
return aspectRatio;
}
export function formatAspectRatioForCSS(aspectRatio: AspectRatio): string {
return aspectRatio.replace(':', '/');
}