This commit is contained in:
ct
2025-06-15 08:27:17 +08:00
parent f686de8f29
commit 361f630293
8 changed files with 215 additions and 72 deletions

View File

@@ -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>
</>
);

View File

@@ -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

View File

@@ -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}

View File

@@ -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;

View 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);

View 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;