Update
This commit is contained in:
@@ -5,6 +5,7 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { initializeTheme } from './hooks/use-appearance';
|
||||
import { AxiosProvider } from './plugins/AxiosContext';
|
||||
import { MittProvider } from './plugins/MittContext';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
|
||||
@@ -16,9 +17,11 @@ createInertiaApp({
|
||||
|
||||
const app = (
|
||||
<>
|
||||
<AxiosProvider>
|
||||
<App {...props} />
|
||||
</AxiosProvider>
|
||||
<MittProvider>
|
||||
<AxiosProvider>
|
||||
<App {...props} />
|
||||
</AxiosProvider>
|
||||
</MittProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useMitt } from '@/plugins/MittContext';
|
||||
import useVideoEditorStore from '@/stores/VideoEditorStore';
|
||||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
import { fetchFile, toBlobURL } from '@ffmpeg/util';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -15,7 +17,7 @@ const VideoEditor = ({ width, height }) => {
|
||||
type: 'video',
|
||||
source_webm: 'https://cdn.memeaigen.com/g1/webm/they-not-like-us-oiia-cat-version.webm',
|
||||
source_mov: 'https://cdn.memeaigen.com/g1/mov/they-not-like-us-oiia-cat-version.mov',
|
||||
poster: 'https://cdn.memeaigen.com/g1/webm/they-not-like-us-oiia-cat-version.webp',
|
||||
poster: 'https://cdn.memeaigen.com/g1/webp/they-not-like-us-oiia-cat-version.webp',
|
||||
name: 'They not like us cat',
|
||||
startTime: 0,
|
||||
layer: 0,
|
||||
@@ -29,7 +31,9 @@ const VideoEditor = ({ width, height }) => {
|
||||
{
|
||||
id: '2',
|
||||
type: 'video',
|
||||
source: 'https://cdn.memeaigen.com/g1/webm/sad-cat.webm',
|
||||
source_webm: 'https://cdn.memeaigen.com/g1/webm/sad-cat.webm',
|
||||
source_mov: 'https://cdn.memeaigen.com/g1/mov/sad-cat.mov',
|
||||
poster: 'https://cdn.memeaigen.com/g1/webp/sad-cat.webp',
|
||||
name: 'Sad cat meme',
|
||||
startTime: 6,
|
||||
layer: 0,
|
||||
@@ -43,7 +47,9 @@ const VideoEditor = ({ width, height }) => {
|
||||
{
|
||||
id: '3',
|
||||
type: 'video',
|
||||
source: 'https://cdn.memeaigen.com/g1/webm/este-cat-dance.webm',
|
||||
source_webm: 'https://cdn.memeaigen.com/g1/webm/este-cat-dance.webm',
|
||||
source_mov: 'https://cdn.memeaigen.com/g1/mov/este-cat-dance.mov',
|
||||
poster: 'https://cdn.memeaigen.com/g1/webp/este-cat-dance.webp',
|
||||
name: 'Este cat dance',
|
||||
startTime: 2,
|
||||
layer: 1,
|
||||
@@ -89,6 +95,8 @@ const VideoEditor = ({ width, height }) => {
|
||||
// FFmpeg WASM states
|
||||
const ffmpegRef = useRef(new FFmpeg());
|
||||
|
||||
const emitter = useMitt();
|
||||
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportProgress, setExportProgress] = useState(0);
|
||||
const [exportStatus, setExportStatus] = useState('');
|
||||
@@ -107,6 +115,12 @@ const VideoEditor = ({ width, height }) => {
|
||||
const startTimeRef = useRef(0);
|
||||
const pausedTimeRef = useRef(0);
|
||||
|
||||
const { setVideoIsPlaying } = useVideoEditorStore();
|
||||
|
||||
useEffect(() => {
|
||||
setVideoIsPlaying(isPlaying);
|
||||
}, [isPlaying]);
|
||||
|
||||
// Calculate total timeline duration
|
||||
const totalDuration = Math.max(...timelineElements.map((el) => el.startTime + el.duration));
|
||||
|
||||
@@ -244,18 +258,18 @@ const VideoEditor = ({ width, height }) => {
|
||||
}, [ffmpegCommand]);
|
||||
|
||||
// Create video elements
|
||||
// Replace your existing useEffect with this:
|
||||
useEffect(() => {
|
||||
const videoEls = {};
|
||||
const videoElementsData = timelineElements.filter((el) => el.type === 'video');
|
||||
|
||||
videoElementsData.forEach((element) => {
|
||||
const video = document.createElement('video');
|
||||
video.poster = element.poster;
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.muted = true; // Start muted, unmute on play
|
||||
video.preload = 'auto'; // Preload entire video content
|
||||
video.playsInline = true; // Better mobile performance
|
||||
video.controls = false; // Remove native controls
|
||||
video.muted = true;
|
||||
video.preload = 'metadata';
|
||||
video.playsInline = true;
|
||||
video.controls = false;
|
||||
|
||||
const sourceWebM = document.createElement('source');
|
||||
sourceWebM.src = element.source_webm;
|
||||
@@ -268,31 +282,34 @@ const VideoEditor = ({ width, height }) => {
|
||||
video.appendChild(sourceMov);
|
||||
video.appendChild(sourceWebM);
|
||||
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
// Calculate scaling to fit within canvas while maintaining aspect ratio
|
||||
// Load poster separately
|
||||
const posterImg = new Image();
|
||||
posterImg.crossOrigin = 'anonymous';
|
||||
posterImg.src = element.poster;
|
||||
|
||||
posterImg.onload = () => {
|
||||
// Calculate scaling for poster
|
||||
const maxWidth = dimensions.width;
|
||||
const maxHeight = dimensions.height;
|
||||
const videoWidth = video.videoWidth;
|
||||
const videoHeight = video.videoHeight;
|
||||
const posterWidth = posterImg.naturalWidth;
|
||||
const posterHeight = posterImg.naturalHeight;
|
||||
|
||||
let scaledWidth = videoWidth;
|
||||
let scaledHeight = videoHeight;
|
||||
let scaledWidth = posterWidth;
|
||||
let scaledHeight = posterHeight;
|
||||
|
||||
// Only scale down if video is larger than canvas
|
||||
if (videoWidth > maxWidth || videoHeight > maxHeight) {
|
||||
const scaleX = maxWidth / videoWidth;
|
||||
const scaleY = maxHeight / videoHeight;
|
||||
const scale = Math.min(scaleX, scaleY); // Use smaller scale to fit both dimensions
|
||||
if (posterWidth > maxWidth || posterHeight > maxHeight) {
|
||||
const scaleX = maxWidth / posterWidth;
|
||||
const scaleY = maxHeight / posterHeight;
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
scaledWidth = videoWidth * scale;
|
||||
scaledHeight = videoHeight * scale;
|
||||
scaledWidth = posterWidth * scale;
|
||||
scaledHeight = posterHeight * scale;
|
||||
}
|
||||
|
||||
// Center the video in the canvas
|
||||
const centeredX = (maxWidth - scaledWidth) / 2;
|
||||
const centeredY = (maxHeight - scaledHeight) / 2;
|
||||
|
||||
// Update timeline element with scaled and centered video
|
||||
// Update timeline element with poster
|
||||
setTimelineElements((prev) =>
|
||||
prev.map((el) => {
|
||||
if (el.id === element.id && el.type === 'video') {
|
||||
@@ -302,6 +319,8 @@ const VideoEditor = ({ width, height }) => {
|
||||
y: centeredY,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
posterImage: posterImg, // Store poster reference
|
||||
isVideoPoster: true, // Flag to indicate poster is loaded
|
||||
};
|
||||
}
|
||||
return el;
|
||||
@@ -313,12 +332,32 @@ const VideoEditor = ({ width, height }) => {
|
||||
newSet.add(element.id);
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
// Video metadata loaded - store video reference
|
||||
setTimelineElements((prev) =>
|
||||
prev.map((el) => {
|
||||
if (el.id === element.id && el.type === 'video') {
|
||||
return {
|
||||
...el,
|
||||
videoElement: video, // Store video reference
|
||||
isVideoReady: true, // Flag to indicate video is ready
|
||||
};
|
||||
}
|
||||
return el;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
video.addEventListener('error', (e) => {
|
||||
console.error(`Error loading video ${element.id}:`, e);
|
||||
});
|
||||
|
||||
posterImg.onerror = (e) => {
|
||||
console.error(`Error loading poster ${element.id}:`, e);
|
||||
};
|
||||
|
||||
videoEls[element.id] = video;
|
||||
});
|
||||
|
||||
@@ -330,7 +369,7 @@ const VideoEditor = ({ width, height }) => {
|
||||
video.load();
|
||||
});
|
||||
};
|
||||
}, []); // Only run once on mount
|
||||
}, [dimensions.width, dimensions.height]); // Add dimensions as dependency
|
||||
|
||||
// Update status when videos load
|
||||
useEffect(() => {
|
||||
@@ -538,7 +577,7 @@ const VideoEditor = ({ width, height }) => {
|
||||
}, [currentTime, isPlaying, videoElements, getDesiredVideoStates]);
|
||||
|
||||
const animate = useCallback(() => {
|
||||
if (!isPlaying) return;
|
||||
if (!animationRef.current || !isPlaying) return;
|
||||
|
||||
const now = Date.now() / 1000;
|
||||
const newTime = pausedTimeRef.current + (now - startTimeRef.current);
|
||||
@@ -617,6 +656,28 @@ const VideoEditor = ({ width, height }) => {
|
||||
|
||||
const activeElements = getActiveElements(currentTime);
|
||||
|
||||
useEffect(() => {
|
||||
emitter.on('video-play', () => {
|
||||
console.log('video-play');
|
||||
handlePlay();
|
||||
});
|
||||
|
||||
emitter.on('video-pause', () => {
|
||||
console.log('video-pause');
|
||||
handlePause();
|
||||
});
|
||||
|
||||
emitter.on('video-seek', (time) => {
|
||||
handleSeek(time);
|
||||
});
|
||||
|
||||
return () => {
|
||||
emitter.off('video-play');
|
||||
emitter.off('video-pause');
|
||||
emitter.off('video-seek');
|
||||
};
|
||||
}, [emitter]);
|
||||
|
||||
return (
|
||||
<div style={{ width: dimensions.width, height: dimensions.height }} className="rounded-3xl">
|
||||
<VideoPreview
|
||||
|
||||
@@ -38,16 +38,40 @@ const VideoPreview = ({
|
||||
// Refs
|
||||
layerRef,
|
||||
}) => {
|
||||
// Function to determine which image source to use for videos
|
||||
const getImageSource = (element) => {
|
||||
// Check if this video should be playing right now
|
||||
const isVideoActive = videoStates[element.id] && isPlaying;
|
||||
|
||||
// Use video if it's ready and currently active, otherwise use poster
|
||||
if (isVideoActive && element.videoElement && element.isVideoReady) {
|
||||
return element.videoElement;
|
||||
} else if (element.posterImage && element.isVideoPoster) {
|
||||
return element.posterImage;
|
||||
}
|
||||
|
||||
// Fallback - no image ready yet
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>isPlaying: {isPlaying ? 'true' : 'false'}</div>
|
||||
|
||||
<Stage width={dimensions.width} height={dimensions.height} className="">
|
||||
<Layer ref={layerRef}>
|
||||
{activeElements.map((element) => {
|
||||
if (element.type === 'video' && videoElements[element.id]) {
|
||||
if (element.type === 'video') {
|
||||
const imageSource = getImageSource(element);
|
||||
|
||||
if (!imageSource) {
|
||||
return null; // Don't render if no source is ready
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
key={element.id}
|
||||
image={videoElements[element.id]}
|
||||
image={imageSource}
|
||||
x={element.x}
|
||||
y={element.y}
|
||||
width={element.width}
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Play, Type, Edit3, Download } from "lucide-react"
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useMitt } from '@/plugins/MittContext';
|
||||
import useVideoEditorStore from '@/stores/VideoEditorStore';
|
||||
import { Download, Edit3, Pause, Play, Type } from 'lucide-react';
|
||||
|
||||
const EditorControls = ({ className = '', onEditClick = () => {}, isEditActive = false }) => {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-12 h-12 rounded-full shadow-sm border "
|
||||
>
|
||||
<Play className="h-8 w-8 " />
|
||||
</Button>
|
||||
const { videoIsPlaying } = useVideoEditorStore();
|
||||
const emitter = useMitt();
|
||||
|
||||
{/* <Button
|
||||
const handlePlay = () => {
|
||||
emitter.emit('video-play');
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
emitter.emit('video-pause');
|
||||
};
|
||||
|
||||
const togglePlayPause = () => {
|
||||
if (videoIsPlaying) {
|
||||
handlePause();
|
||||
} else {
|
||||
handlePlay();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center gap-2', className)}>
|
||||
<Button onClick={togglePlayPause} variant="ghost" size="icon" className="h-12 w-12 rounded-full border shadow-sm">
|
||||
{videoIsPlaying ? <Pause className="h-8 w-8" /> : <Play className="h-8 w-8" />}
|
||||
</Button>
|
||||
|
||||
{/* <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-12 h-12 rounded-full shadow-sm border "
|
||||
@@ -23,35 +40,25 @@ const EditorControls = ({ className = '', onEditClick = () => {}, isEditActive =
|
||||
<span className="text-sm font-medium ">9:16</span>
|
||||
</Button> */}
|
||||
|
||||
<Button variant="ghost" size="icon" className="h-12 w-12 rounded-full border shadow-sm">
|
||||
<Type className="h-8 w-8" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-12 h-12 rounded-full shadow-sm border "
|
||||
>
|
||||
<Type className="h-8 w-8 " />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
id="edit"
|
||||
variant={isEditActive ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className="w-12 h-12 rounded-full shadow-sm border"
|
||||
onClick={onEditClick}
|
||||
>
|
||||
<Edit3 className={`h-8 w-8 ${isEditActive ? "text-white" : ""}`} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-12 h-12 rounded-full shadow-sm border "
|
||||
>
|
||||
<Download className="h-8 w-8 " />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<Button
|
||||
id="edit"
|
||||
variant={isEditActive ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-full border shadow-sm"
|
||||
onClick={onEditClick}
|
||||
>
|
||||
<Edit3 className={`h-8 w-8 ${isEditActive ? 'text-white' : ''}`} />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon" className="h-12 w-12 rounded-full border shadow-sm">
|
||||
<Download className="h-8 w-8" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorControls;
|
||||
|
||||
13
resources/js/plugins/MittContext.jsx
Normal file
13
resources/js/plugins/MittContext.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
// resources/js/Plugins/MittContext.jsx
|
||||
import mitt from 'mitt';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const emitter = mitt();
|
||||
|
||||
const MittContext = createContext(emitter);
|
||||
|
||||
export const MittProvider = ({ children }) => {
|
||||
return <MittContext.Provider value={emitter}>{children}</MittContext.Provider>;
|
||||
};
|
||||
|
||||
export const useMitt = () => useContext(MittContext);
|
||||
27
resources/js/stores/VideoEditorStore.ts
Normal file
27
resources/js/stores/VideoEditorStore.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import axiosInstance from '@/plugins/axios-plugin';
|
||||
import { mountStoreDevtool } from 'simple-zustand-devtools';
|
||||
import { toast } from 'sonner';
|
||||
import { route } from 'ziggy-js';
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
const useVideoEditorStore = create(
|
||||
devtools((set, get) => ({
|
||||
videoIsPlaying: false,
|
||||
|
||||
setVideoIsPlaying: (isPlaying) => {
|
||||
set({ videoIsPlaying: isPlaying });
|
||||
},
|
||||
})),
|
||||
|
||||
{
|
||||
name: 'VideoEditorStore',
|
||||
store: 'VideoEditorStore',
|
||||
},
|
||||
);
|
||||
|
||||
if (import.meta.env.APP_ENV === 'local') {
|
||||
mountStoreDevtool('VideoEditorStore', useVideoEditorStore);
|
||||
}
|
||||
|
||||
export default useVideoEditorStore;
|
||||
Reference in New Issue
Block a user