Update
This commit is contained in:
@@ -36,6 +36,7 @@ export default [
|
|||||||
rules: {
|
rules: {
|
||||||
'react-hooks/rules-of-hooks': 'error',
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
'react-hooks/exhaustive-deps': 'off',
|
'react-hooks/exhaustive-deps': 'off',
|
||||||
|
'prefer-const': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
1833
package-lock.json
generated
1833
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@
|
|||||||
"typescript-eslint": "^8.23.0"
|
"typescript-eslint": "^8.23.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||||
|
"@ffmpeg/util": "^0.12.2",
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@inertiajs/react": "^2.0.0",
|
"@inertiajs/react": "^2.0.0",
|
||||||
@@ -65,6 +67,7 @@
|
|||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"konva": "^9.3.20",
|
||||||
"laravel-vite-plugin": "^1.0",
|
"laravel-vite-plugin": "^1.0",
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -72,6 +75,7 @@
|
|||||||
"react-day-picker": "^9.7.0",
|
"react-day-picker": "^9.7.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.57.0",
|
"react-hook-form": "^7.57.0",
|
||||||
|
"react-konva": "^19.0.6",
|
||||||
"react-resizable-panels": "^3.0.2",
|
"react-resizable-panels": "^3.0.2",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
"simple-zustand-devtools": "^1.1.0",
|
"simple-zustand-devtools": "^1.1.0",
|
||||||
|
|||||||
317
public/ffmpeg/ffmpeg-core.js
Normal file
317
public/ffmpeg/ffmpeg-core.js
Normal file
File diff suppressed because one or more lines are too long
65
public/ffmpeg/ffmpeg-core.wasm
Normal file
65
public/ffmpeg/ffmpeg-core.wasm
Normal file
File diff suppressed because one or more lines are too long
66
public/ffmpeg/ffmpeg-core.worker.js
Normal file
66
public/ffmpeg/ffmpeg-core.worker.js
Normal file
File diff suppressed because one or more lines are too long
@@ -28,7 +28,7 @@ export default function EditSidebar({ isOpen, onClose }: EditSidebarProps) {
|
|||||||
} = useMediaStore();
|
} = useMediaStore();
|
||||||
|
|
||||||
// Track the current active tab
|
// Track the current active tab
|
||||||
const [activeTab, setActiveTab] = useState('backgrounds');
|
const [activeTab, setActiveTab] = useState('memes');
|
||||||
|
|
||||||
// Fetch data when sidebar opens for the current active tab
|
// Fetch data when sidebar opens for the current active tab
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -71,20 +71,6 @@ export default function EditSidebar({ isOpen, onClose }: EditSidebarProps) {
|
|||||||
{/* Currently Selected Items */}
|
{/* Currently Selected Items */}
|
||||||
<div className="pb-6">
|
<div className="pb-6">
|
||||||
<div className="flex justify-center gap-3">
|
<div className="flex justify-center gap-3">
|
||||||
{/* Selected Background */}
|
|
||||||
<div className="text-center">
|
|
||||||
{selectedBackground ? (
|
|
||||||
<div className="aspect-[9/16] h-[200px] overflow-hidden rounded-lg bg-gray-100">
|
|
||||||
<img src={selectedBackground.media_url} alt="Selected Background" className="h-full w-full object-cover" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex aspect-[9/16] h-[200px] items-center justify-center rounded-lg border-2 border-dashed border-gray-300 p-2 text-center text-xs text-gray-500">
|
|
||||||
No background
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="mb-1 text-xs text-gray-600">Background</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selected Meme */}
|
{/* Selected Meme */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
{selectedMeme ? (
|
{selectedMeme ? (
|
||||||
@@ -98,6 +84,20 @@ export default function EditSidebar({ isOpen, onClose }: EditSidebarProps) {
|
|||||||
)}
|
)}
|
||||||
<p className="mb-1 text-xs text-gray-600">Meme Overlay</p>
|
<p className="mb-1 text-xs text-gray-600">Meme Overlay</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Background */}
|
||||||
|
<div className="text-center">
|
||||||
|
{selectedBackground ? (
|
||||||
|
<div className="aspect-[9/16] h-[200px] overflow-hidden rounded-lg bg-gray-100">
|
||||||
|
<img src={selectedBackground.media_url} alt="Selected Background" className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-[9/16] h-[200px] items-center justify-center rounded-lg border-2 border-dashed border-gray-300 p-2 text-center text-xs text-gray-500">
|
||||||
|
No background
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mb-1 text-xs text-gray-600">Background</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import React, { useState, useEffect, useLayoutEffect } from 'react';
|
import useMediaStore from '@/stores/MediaStore';
|
||||||
|
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||||
|
import { fetchFile, toBlobURL } from '@ffmpeg/util';
|
||||||
|
import 'konva/lib/Animation';
|
||||||
|
import 'konva/lib/shapes/Image';
|
||||||
|
import 'konva/lib/shapes/Text';
|
||||||
|
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { Image, Layer, Stage, Text } from 'react-konva/lib/ReactKonvaCore';
|
||||||
import { LAYOUT_CONSTANTS, calculateResponsiveScale } from '../utils/layout-constants';
|
import { LAYOUT_CONSTANTS, calculateResponsiveScale } from '../utils/layout-constants';
|
||||||
|
|
||||||
const useResponsiveCanvas = (maxWidth: number = 350) => {
|
const useResponsiveCanvas = (maxWidth: number = 350) => {
|
||||||
@@ -13,21 +20,16 @@ const useResponsiveCanvas = (maxWidth: number = 350) => {
|
|||||||
setScale(calculateResponsiveScale(maxWidth));
|
setScale(calculateResponsiveScale(maxWidth));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update immediately
|
|
||||||
handleResize();
|
handleResize();
|
||||||
|
|
||||||
// Event listeners
|
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
window.addEventListener('orientationchange', handleResize);
|
window.addEventListener('orientationchange', handleResize);
|
||||||
|
|
||||||
// ResizeObserver for more reliable detection
|
|
||||||
let resizeObserver: ResizeObserver | undefined;
|
let resizeObserver: ResizeObserver | undefined;
|
||||||
if (window.ResizeObserver) {
|
if (window.ResizeObserver) {
|
||||||
resizeObserver = new ResizeObserver(handleResize);
|
resizeObserver = new ResizeObserver(handleResize);
|
||||||
resizeObserver.observe(document.body);
|
resizeObserver.observe(document.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// MutationObserver for dev tools detection
|
|
||||||
let mutationObserver: MutationObserver | undefined;
|
let mutationObserver: MutationObserver | undefined;
|
||||||
if (window.MutationObserver) {
|
if (window.MutationObserver) {
|
||||||
mutationObserver = new MutationObserver(() => {
|
mutationObserver = new MutationObserver(() => {
|
||||||
@@ -35,7 +37,7 @@ const useResponsiveCanvas = (maxWidth: number = 350) => {
|
|||||||
});
|
});
|
||||||
mutationObserver.observe(document.documentElement, {
|
mutationObserver.observe(document.documentElement, {
|
||||||
attributes: true,
|
attributes: true,
|
||||||
attributeFilter: ['style']
|
attributeFilter: ['style'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,16 +61,441 @@ const EditorCanvas: React.FC<EditorCanvasProps> = ({maxWidth = 350}) => {
|
|||||||
const displayWidth = LAYOUT_CONSTANTS.CANVAS_WIDTH * scale;
|
const displayWidth = LAYOUT_CONSTANTS.CANVAS_WIDTH * scale;
|
||||||
const displayHeight = LAYOUT_CONSTANTS.CANVAS_HEIGHT * scale;
|
const displayHeight = LAYOUT_CONSTANTS.CANVAS_HEIGHT * scale;
|
||||||
|
|
||||||
const convertCoordinates = (e) => {
|
const { selectedBackground, selectedMeme } = useMediaStore();
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
|
||||||
return {
|
// Timeline state (hidden from UI)
|
||||||
x: (e.clientX - rect.left) / scale,
|
const [timelineElements, setTimelineElements] = useState([]);
|
||||||
y: (e.clientY - rect.top) / scale
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [videoElements, setVideoElements] = useState({});
|
||||||
|
const [imageElements, setImageElements] = useState({});
|
||||||
|
const [loadedVideos, setLoadedVideos] = useState(new Set());
|
||||||
|
const [videoStates, setVideoStates] = useState({});
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
const animationRef = useRef(null);
|
||||||
|
const layerRef = useRef(null);
|
||||||
|
const startTimeRef = useRef(0);
|
||||||
|
const pausedTimeRef = useRef(0);
|
||||||
|
const lastUpdateRef = useRef(0);
|
||||||
|
|
||||||
|
// Generate timeline elements from MediaStore selections
|
||||||
|
useEffect(() => {
|
||||||
|
const elements = [];
|
||||||
|
|
||||||
|
// Background (full duration)
|
||||||
|
if (selectedBackground) {
|
||||||
|
elements.push({
|
||||||
|
id: 'background',
|
||||||
|
type: 'image',
|
||||||
|
source: selectedBackground.media_url,
|
||||||
|
poster: selectedBackground.media_url,
|
||||||
|
name: 'Background',
|
||||||
|
startTime: 0,
|
||||||
|
layer: 1,
|
||||||
|
duration: 10, // Default 10 seconds
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: LAYOUT_CONSTANTS.CANVAS_WIDTH,
|
||||||
|
height: LAYOUT_CONSTANTS.CANVAS_HEIGHT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meme overlay (shorter duration, centered)
|
||||||
|
if (selectedMeme) {
|
||||||
|
const memeWidth = LAYOUT_CONSTANTS.CANVAS_WIDTH * 0.8;
|
||||||
|
const memeHeight = LAYOUT_CONSTANTS.CANVAS_HEIGHT * 0.6;
|
||||||
|
const memeX = (LAYOUT_CONSTANTS.CANVAS_WIDTH - memeWidth) / 2;
|
||||||
|
const memeY = (LAYOUT_CONSTANTS.CANVAS_HEIGHT - memeHeight) / 2;
|
||||||
|
|
||||||
|
elements.push({
|
||||||
|
id: 'meme',
|
||||||
|
type: 'video',
|
||||||
|
source_webm: selectedMeme.webm_url,
|
||||||
|
source_mov: selectedMeme.mov_url,
|
||||||
|
poster: selectedMeme.webp_url,
|
||||||
|
name: 'Meme',
|
||||||
|
startTime: 0,
|
||||||
|
layer: 0,
|
||||||
|
inPoint: 0,
|
||||||
|
duration: 6,
|
||||||
|
x: memeX,
|
||||||
|
y: memeY,
|
||||||
|
width: memeWidth,
|
||||||
|
height: memeHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimelineElements(elements);
|
||||||
|
}, [selectedBackground, selectedMeme]);
|
||||||
|
|
||||||
|
// Calculate total duration
|
||||||
|
const totalDuration = Math.max(...timelineElements.map((el) => el.startTime + el.duration), 1);
|
||||||
|
|
||||||
|
// Update video times
|
||||||
|
const updateVideoTimes = useCallback(
|
||||||
|
(time) => {
|
||||||
|
timelineElements.forEach((element) => {
|
||||||
|
if (element.type === 'video' && videoElements[element.id]) {
|
||||||
|
const video = videoElements[element.id];
|
||||||
|
const elementEndTime = element.startTime + element.duration;
|
||||||
|
|
||||||
|
if (time >= element.startTime && time < elementEndTime) {
|
||||||
|
const relativeTime = time - element.startTime;
|
||||||
|
const videoTime = (element.inPoint || 0) + relativeTime;
|
||||||
|
|
||||||
|
if (Math.abs(video.currentTime - videoTime) > 0.1) {
|
||||||
|
video.currentTime = videoTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[timelineElements, videoElements],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create media elements when timeline changes
|
||||||
|
useEffect(() => {
|
||||||
|
const videoEls = {};
|
||||||
|
const imageEls = {};
|
||||||
|
|
||||||
|
timelineElements.forEach((element) => {
|
||||||
|
if (element.type === 'video') {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.poster = element.poster;
|
||||||
|
video.crossOrigin = 'anonymous';
|
||||||
|
video.muted = true;
|
||||||
|
video.preload = 'auto';
|
||||||
|
video.playsInline = true;
|
||||||
|
video.controls = false;
|
||||||
|
video.loop = true;
|
||||||
|
|
||||||
|
if (element.source) {
|
||||||
|
video.src = element.source;
|
||||||
|
} else if (element.source_webm && element.source_mov) {
|
||||||
|
const sourceWebm = document.createElement('source');
|
||||||
|
sourceWebm.src = element.source_webm;
|
||||||
|
sourceWebm.type = 'video/webm';
|
||||||
|
|
||||||
|
const sourceMov = document.createElement('source');
|
||||||
|
sourceMov.src = element.source_mov;
|
||||||
|
sourceMov.type = 'video/mp4';
|
||||||
|
|
||||||
|
video.appendChild(sourceWebm);
|
||||||
|
video.appendChild(sourceMov);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture element data to avoid variable shadowing
|
||||||
|
const elementId = element.id;
|
||||||
|
const elementData = { ...element };
|
||||||
|
|
||||||
|
video.addEventListener('loadedmetadata', () => {
|
||||||
|
// Set initial time position based on current timeline position
|
||||||
|
if (currentTime >= elementData.startTime && currentTime < elementData.startTime + elementData.duration) {
|
||||||
|
const relativeTime = currentTime - elementData.startTime;
|
||||||
|
const videoTime = (elementData.inPoint || 0) + relativeTime;
|
||||||
|
video.currentTime = videoTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadedVideos((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.add(elementId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force a canvas redraw after video loads
|
||||||
|
if (layerRef.current) {
|
||||||
|
layerRef.current.batchDraw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('loadeddata', () => {
|
||||||
|
// Also trigger redraw when video data is loaded
|
||||||
|
if (layerRef.current) {
|
||||||
|
layerRef.current.batchDraw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
videoEls[element.id] = video;
|
||||||
|
} else if (element.type === 'image') {
|
||||||
|
const img = new window.Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
img.src = element.source;
|
||||||
|
|
||||||
|
img.addEventListener('load', () => {
|
||||||
|
setLoadedVideos((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.add(element.id);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force a canvas redraw after image loads
|
||||||
|
if (layerRef.current) {
|
||||||
|
layerRef.current.batchDraw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
imageEls[element.id] = img;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setVideoElements(videoEls);
|
||||||
|
setImageElements(imageEls);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Object.values(videoEls).forEach((video) => {
|
||||||
|
video.src = '';
|
||||||
|
video.load();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
}, [timelineElements]);
|
||||||
|
|
||||||
|
// Update video times whenever currentTime changes (for paused state)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying) {
|
||||||
|
updateVideoTimes(currentTime);
|
||||||
|
}
|
||||||
|
}, [currentTime, updateVideoTimes, isPlaying]);
|
||||||
|
|
||||||
|
// Get active elements at current time
|
||||||
|
const getActiveElements = useCallback(
|
||||||
|
(time) => {
|
||||||
|
return timelineElements.filter((element) => {
|
||||||
|
const elementEndTime = element.startTime + element.duration;
|
||||||
|
return time >= element.startTime && time < elementEndTime;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[timelineElements],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Manage video playback states
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying) return;
|
||||||
|
|
||||||
|
timelineElements.forEach((element) => {
|
||||||
|
if (element.type === 'video' && videoElements[element.id]) {
|
||||||
|
const video = videoElements[element.id];
|
||||||
|
const elementEndTime = element.startTime + element.duration;
|
||||||
|
const shouldPlay = currentTime >= element.startTime && currentTime < elementEndTime;
|
||||||
|
|
||||||
|
if (shouldPlay && video.paused) {
|
||||||
|
video.muted = false;
|
||||||
|
video.play().catch(() => {});
|
||||||
|
} else if (!shouldPlay && !video.paused) {
|
||||||
|
video.pause();
|
||||||
|
video.muted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [currentTime, isPlaying, timelineElements, videoElements]);
|
||||||
|
|
||||||
|
// Animation loop
|
||||||
|
const animate = useCallback(() => {
|
||||||
|
if (!isPlaying) return;
|
||||||
|
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const newTime = pausedTimeRef.current + (now - startTimeRef.current);
|
||||||
|
|
||||||
|
if (newTime >= totalDuration) {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
pausedTimeRef.current = 0;
|
||||||
|
|
||||||
|
Object.values(videoElements).forEach((video) => {
|
||||||
|
video.pause();
|
||||||
|
video.muted = true;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newTime - lastUpdateRef.current >= 0.05) {
|
||||||
|
lastUpdateRef.current = newTime;
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
|
||||||
|
if (layerRef.current) {
|
||||||
|
layerRef.current.batchDraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isPlaying, totalDuration, videoElements]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPlaying) {
|
||||||
|
let animationId;
|
||||||
|
|
||||||
|
const animateFrame = () => {
|
||||||
|
animate();
|
||||||
|
animationId = requestAnimationFrame(animateFrame);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
animationId = requestAnimationFrame(animateFrame);
|
||||||
|
animationRef.current = { stop: () => cancelAnimationFrame(animationId) };
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationRef.current) {
|
||||||
|
animationRef.current.stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isPlaying, animate]);
|
||||||
|
|
||||||
|
// Export function for Download button
|
||||||
|
const exportVideo = async () => {
|
||||||
|
if (isExporting) return;
|
||||||
|
|
||||||
|
setIsExporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ffmpeg = new FFmpeg();
|
||||||
|
|
||||||
|
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
|
||||||
|
const coreURL = `${baseURL}/ffmpeg-core.js`;
|
||||||
|
const wasmURL = `${baseURL}/ffmpeg-core.wasm`;
|
||||||
|
|
||||||
|
const coreBlobURL = await toBlobURL(coreURL, 'text/javascript');
|
||||||
|
const wasmBlobURL = await toBlobURL(wasmURL, 'application/wasm');
|
||||||
|
|
||||||
|
await ffmpeg.load({
|
||||||
|
coreURL: coreBlobURL,
|
||||||
|
wasmURL: wasmBlobURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download media files
|
||||||
|
const videos = timelineElements.filter((el) => el.type === 'video');
|
||||||
|
const images = timelineElements.filter((el) => el.type === 'image');
|
||||||
|
|
||||||
|
for (let i = 0; i < videos.length; i++) {
|
||||||
|
await ffmpeg.writeFile(`video${i}.webm`, await fetchFile(videos[i].source));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
await ffmpeg.writeFile(`image${i}.jpg`, await fetchFile(images[i].source));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate FFmpeg command
|
||||||
|
let inputArgs = [];
|
||||||
|
let filters = [];
|
||||||
|
|
||||||
|
// Add inputs
|
||||||
|
videos.forEach((v, i) => {
|
||||||
|
inputArgs.push('-i', `video${i}.webm`);
|
||||||
|
});
|
||||||
|
images.forEach((img, i) => {
|
||||||
|
inputArgs.push('-i', `image${videos.length + i}.jpg`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Base canvas
|
||||||
|
filters.push(`color=black:size=${LAYOUT_CONSTANTS.CANVAS_WIDTH}x${LAYOUT_CONSTANTS.CANVAS_HEIGHT}:duration=${totalDuration}[base]`);
|
||||||
|
|
||||||
|
let currentLayer = 'base';
|
||||||
|
|
||||||
|
// Process images first (backgrounds)
|
||||||
|
images.forEach((img, i) => {
|
||||||
|
const inputIndex = videos.length + i;
|
||||||
|
filters.push(`[${inputIndex}:v]scale=${Math.round(img.width)}:${Math.round(img.height)}[img${i}_scale]`);
|
||||||
|
filters.push(
|
||||||
|
`[${currentLayer}][img${i}_scale]overlay=${Math.round(img.x)}:${Math.round(img.y)}:enable='between(t,${img.startTime},${img.startTime + img.duration})'[img${i}_out]`,
|
||||||
|
);
|
||||||
|
currentLayer = `img${i}_out`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process videos
|
||||||
|
videos.forEach((v, i) => {
|
||||||
|
filters.push(`[${i}:v]trim=start=${v.inPoint || 0}:duration=${v.duration},setpts=PTS-STARTPTS[v${i}_trim]`);
|
||||||
|
filters.push(`[v${i}_trim]scale=${Math.round(v.width)}:${Math.round(v.height)}[v${i}_scale]`);
|
||||||
|
filters.push(
|
||||||
|
`[${currentLayer}][v${i}_scale]overlay=${Math.round(v.x)}:${Math.round(v.y)}:enable='between(t,${v.startTime},${v.startTime + v.duration})'[v${i}_out]`,
|
||||||
|
);
|
||||||
|
currentLayer = `v${i}_out`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterComplex = filters.join('; ');
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
...inputArgs,
|
||||||
|
'-filter_complex',
|
||||||
|
filterComplex,
|
||||||
|
'-map',
|
||||||
|
`[${currentLayer}]`,
|
||||||
|
'-c:v',
|
||||||
|
'libx264',
|
||||||
|
'-pix_fmt',
|
||||||
|
'yuv420p',
|
||||||
|
'-r',
|
||||||
|
'30',
|
||||||
|
'-t',
|
||||||
|
totalDuration.toString(),
|
||||||
|
'output.mp4',
|
||||||
|
];
|
||||||
|
|
||||||
|
await ffmpeg.exec(args);
|
||||||
|
|
||||||
|
const fileData = await ffmpeg.readFile('output.mp4');
|
||||||
|
const data = new Uint8Array(fileData);
|
||||||
|
const blob = new Blob([data.buffer], { type: 'video/mp4' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'meme_video.mp4';
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
ffmpeg.terminate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export error:', error);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expose controls to parent (will be used by buttons)
|
||||||
|
useEffect(() => {
|
||||||
|
window.timelineControls = {
|
||||||
|
play: () => {
|
||||||
|
if (!isPlaying) {
|
||||||
|
setIsPlaying(true);
|
||||||
|
startTimeRef.current = Date.now() / 1000;
|
||||||
|
lastUpdateRef.current = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pause: () => {
|
||||||
|
if (isPlaying) {
|
||||||
|
setIsPlaying(false);
|
||||||
|
pausedTimeRef.current = currentTime;
|
||||||
|
|
||||||
|
Object.values(videoElements).forEach((video) => {
|
||||||
|
if (!video.paused) {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
video.muted = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (animationRef.current) {
|
||||||
|
animationRef.current.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reset: () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
pausedTimeRef.current = 0;
|
||||||
|
|
||||||
|
Object.values(videoElements).forEach((video) => {
|
||||||
|
video.pause();
|
||||||
|
video.muted = true;
|
||||||
|
video.currentTime = 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
export: exportVideo,
|
||||||
|
isPlaying,
|
||||||
|
isExporting,
|
||||||
|
};
|
||||||
|
}, [isPlaying, isExporting, currentTime, videoElements, exportVideo]);
|
||||||
|
|
||||||
|
const activeElements = getActiveElements(currentTime);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex justify-center">
|
<div className="flex w-full justify-center">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: `${displayWidth}px`,
|
width: `${displayWidth}px`,
|
||||||
@@ -76,20 +503,56 @@ const EditorCanvas: React.FC<EditorCanvasProps> = ({maxWidth = 350}) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="border rounded-3xl bg-white shadow-sm origin-top-left"
|
className="origin-top-left overflow-hidden rounded-3xl border bg-black shadow-sm"
|
||||||
style={{
|
style={{
|
||||||
width: `${LAYOUT_CONSTANTS.CANVAS_WIDTH}px`,
|
width: `${LAYOUT_CONSTANTS.CANVAS_WIDTH}px`,
|
||||||
height: `${LAYOUT_CONSTANTS.CANVAS_HEIGHT}px`,
|
height: `${LAYOUT_CONSTANTS.CANVAS_HEIGHT}px`,
|
||||||
transform: `scale(${scale})`,
|
transform: `scale(${scale})`,
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
|
||||||
const { x, y } = convertCoordinates(e);
|
|
||||||
// Handle your canvas interactions here
|
|
||||||
// x, y are the actual canvas coordinates (0-720, 0-1280)
|
|
||||||
console.log(`Canvas coordinates: x=${x}, y=${y}`);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Your canvas content goes here */}
|
<Stage width={LAYOUT_CONSTANTS.CANVAS_WIDTH} height={LAYOUT_CONSTANTS.CANVAS_HEIGHT}>
|
||||||
|
<Layer ref={layerRef}>
|
||||||
|
{activeElements.map((element) => {
|
||||||
|
if (element.type === 'video' && videoElements[element.id]) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
key={element.id}
|
||||||
|
image={videoElements[element.id]}
|
||||||
|
x={element.x}
|
||||||
|
y={element.y}
|
||||||
|
width={element.width}
|
||||||
|
height={element.height}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (element.type === 'image' && imageElements[element.id]) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
key={element.id}
|
||||||
|
image={imageElements[element.id]}
|
||||||
|
x={element.x}
|
||||||
|
y={element.y}
|
||||||
|
width={element.width}
|
||||||
|
height={element.height}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (element.type === 'text') {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={element.id}
|
||||||
|
text={element.text}
|
||||||
|
x={element.x}
|
||||||
|
y={element.y}
|
||||||
|
fontSize={element.fontSize}
|
||||||
|
fill={element.fill}
|
||||||
|
stroke={element.stroke}
|
||||||
|
strokeWidth={element.strokeWidth}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</Layer>
|
||||||
|
</Stage>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,57 +1,80 @@
|
|||||||
"use client"
|
'use client';
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils';
|
||||||
import { Play, Type, Edit3, Download } from "lucide-react"
|
import { Download, Edit3, Loader2, Pause, Play, Type } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
const EditorControls = ({ className = '', onEditClick = () => {}, isEditActive = false }) => {
|
const EditorControls = ({ className = '', onEditClick = () => {}, isEditActive = false }) => {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
// Listen for timeline state changes
|
||||||
|
useEffect(() => {
|
||||||
|
const checkTimelineState = () => {
|
||||||
|
if (window.timelineControls) {
|
||||||
|
setIsPlaying(window.timelineControls.isPlaying);
|
||||||
|
setIsExporting(window.timelineControls.isExporting);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(checkTimelineState, 100);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (window.timelineControls) {
|
||||||
|
if (isPlaying) {
|
||||||
|
window.timelineControls.pause();
|
||||||
|
} else {
|
||||||
|
window.timelineControls.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (window.timelineControls && !isExporting) {
|
||||||
|
window.timelineControls.export();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
<div className={cn('flex items-center justify-center gap-2', className)}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="default"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="w-12 h-12 rounded-full shadow-sm border "
|
className="h-12 w-12 rounded-full border shadow-sm"
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
disabled={!window.timelineControls}
|
||||||
>
|
>
|
||||||
<Play className="h-8 w-8 " />
|
{isPlaying ? <Pause className="h-8 w-8" /> : <Play className="h-8 w-8" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* <Button
|
<Button variant="default" size="icon" className="h-12 w-12 rounded-full border shadow-sm">
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="w-12 h-12 rounded-full shadow-sm border "
|
|
||||||
>
|
|
||||||
<span className="text-sm font-medium ">9:16</span>
|
|
||||||
</Button> */}
|
|
||||||
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="w-12 h-12 rounded-full shadow-sm border "
|
|
||||||
>
|
|
||||||
<Type className="h-8 w-8" />
|
<Type className="h-8 w-8" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
id="edit"
|
id="edit"
|
||||||
variant={isEditActive ? "default" : "ghost"}
|
variant={isEditActive ? 'default' : 'default'}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="w-12 h-12 rounded-full shadow-sm border"
|
className="h-12 w-12 rounded-full border shadow-sm"
|
||||||
onClick={onEditClick}
|
onClick={onEditClick}
|
||||||
>
|
>
|
||||||
<Edit3 className={`h-8 w-8 ${isEditActive ? "text-white" : ""}`} />
|
<Edit3 className={`h-8 w-8 ${isEditActive ? 'text-white' : ''}`} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="default"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="w-12 h-12 rounded-full shadow-sm border "
|
className="h-12 w-12 rounded-full border shadow-sm"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={isExporting || !window.timelineControls}
|
||||||
>
|
>
|
||||||
<Download className="h-8 w-8 " />
|
{isExporting ? <Loader2 className="h-8 w-8 animate-spin" /> : <Download className="h-8 w-8" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
export default EditorControls;
|
export default EditorControls;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const EditorHeader = ({ className = '', onNavClick = () => {}, isNavActive = fal
|
|||||||
const [openCoinDialog, setOpenCoinDialog] = useState(false);
|
const [openCoinDialog, setOpenCoinDialog] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex w-full items-center justify-between rounded-xl bg-white p-2 shadow-sm', className)}>
|
<div className={cn('flex w-full items-center justify-between rounded-xl bg-white p-2 shadow-sm dark:bg-neutral-700', className)}>
|
||||||
<Button onClick={onNavClick} variant="outline" size="icon" className="rounded">
|
<Button onClick={onNavClick} variant="outline" size="icon" className="rounded">
|
||||||
<Menu className="h-8 w-8" />
|
<Menu className="h-8 w-8" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Editor from '@/modules/editor/editor';
|
|||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-50">
|
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-800">
|
||||||
<Editor />
|
<Editor />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user