diff --git a/package-lock.json b/package-lock.json index 556ce81..9028ef4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "konva": "^9.3.20", "laravel-vite-plugin": "^1.0", "lucide-react": "^0.475.0", + "mitt": "^3.0.1", "next-themes": "^0.4.6", "react": "^19.0.0", "react-day-picker": "^9.7.0", @@ -6642,6 +6643,12 @@ "node": ">= 18" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mkdirp": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", diff --git a/package.json b/package.json index bb7c341..e12f6fe 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "konva": "^9.3.20", "laravel-vite-plugin": "^1.0", "lucide-react": "^0.475.0", + "mitt": "^3.0.1", "next-themes": "^0.4.6", "react": "^19.0.0", "react-day-picker": "^9.7.0", diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 3ee0a88..f3e5e5c 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -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 = ( <> - - - + + + + + ); diff --git a/resources/js/modules/editor/partials/canvas/video-editor.jsx b/resources/js/modules/editor/partials/canvas/video-editor.jsx index 7a85011..c0811ec 100644 --- a/resources/js/modules/editor/partials/canvas/video-editor.jsx +++ b/resources/js/modules/editor/partials/canvas/video-editor.jsx @@ -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 (
{ + // 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 (
+
isPlaying: {isPlaying ? 'true' : 'false'}
+ {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 ( {}, isEditActive = false }) => { - return ( -
- + const { videoIsPlaying } = useVideoEditorStore(); + const emitter = useMitt(); - {/* + + {/* */} + - - - - - -
- ) -} + + +
+ ); +}; export default EditorControls; diff --git a/resources/js/plugins/MittContext.jsx b/resources/js/plugins/MittContext.jsx new file mode 100644 index 0000000..fa71b62 --- /dev/null +++ b/resources/js/plugins/MittContext.jsx @@ -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 {children}; +}; + +export const useMitt = () => useContext(MittContext); diff --git a/resources/js/stores/VideoEditorStore.ts b/resources/js/stores/VideoEditorStore.ts new file mode 100644 index 0000000..e9e2aab --- /dev/null +++ b/resources/js/stores/VideoEditorStore.ts @@ -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;