Update
This commit is contained in:
@@ -36,6 +36,7 @@ export default [
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@inertiajs/react": "^2.0.0",
|
||||
@@ -65,6 +67,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"globals": "^15.14.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"konva": "^9.3.20",
|
||||
"laravel-vite-plugin": "^1.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -72,6 +75,7 @@
|
||||
"react-day-picker": "^9.7.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-konva": "^19.0.6",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"recharts": "^2.15.3",
|
||||
"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();
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
@@ -71,20 +71,6 @@ export default function EditSidebar({ isOpen, onClose }: EditSidebarProps) {
|
||||
{/* Currently Selected Items */}
|
||||
<div className="pb-6">
|
||||
<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 */}
|
||||
<div className="text-center">
|
||||
{selectedMeme ? (
|
||||
@@ -98,6 +84,20 @@ export default function EditSidebar({ isOpen, onClose }: EditSidebarProps) {
|
||||
)}
|
||||
<p className="mb-1 text-xs text-gray-600">Meme Overlay</p>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
const useResponsiveCanvas = (maxWidth: number = 350) => {
|
||||
@@ -13,21 +20,16 @@ const useResponsiveCanvas = (maxWidth: number = 350) => {
|
||||
setScale(calculateResponsiveScale(maxWidth));
|
||||
};
|
||||
|
||||
// Update immediately
|
||||
handleResize();
|
||||
|
||||
// Event listeners
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('orientationchange', handleResize);
|
||||
|
||||
// ResizeObserver for more reliable detection
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
if (window.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(document.body);
|
||||
}
|
||||
|
||||
// MutationObserver for dev tools detection
|
||||
let mutationObserver: MutationObserver | undefined;
|
||||
if (window.MutationObserver) {
|
||||
mutationObserver = new MutationObserver(() => {
|
||||
@@ -35,7 +37,7 @@ const useResponsiveCanvas = (maxWidth: number = 350) => {
|
||||
});
|
||||
mutationObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style']
|
||||
attributeFilter: ['style'],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,21 +56,446 @@ interface EditorCanvasProps {
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
const EditorCanvas: React.FC<EditorCanvasProps> = ({maxWidth = 350}) => {
|
||||
const EditorCanvas: React.FC<EditorCanvasProps> = ({ maxWidth = 350 }) => {
|
||||
const scale = useResponsiveCanvas(maxWidth);
|
||||
const displayWidth = LAYOUT_CONSTANTS.CANVAS_WIDTH * scale;
|
||||
const displayHeight = LAYOUT_CONSTANTS.CANVAS_HEIGHT * scale;
|
||||
|
||||
const convertCoordinates = (e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.clientX - rect.left) / scale,
|
||||
y: (e.clientY - rect.top) / scale
|
||||
const { selectedBackground, selectedMeme } = useMediaStore();
|
||||
|
||||
// Timeline state (hidden from UI)
|
||||
const [timelineElements, setTimelineElements] = useState([]);
|
||||
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 (
|
||||
<div className="w-full flex justify-center">
|
||||
<div className="flex w-full justify-center">
|
||||
<div
|
||||
style={{
|
||||
width: `${displayWidth}px`,
|
||||
@@ -76,20 +503,56 @@ const EditorCanvas: React.FC<EditorCanvasProps> = ({maxWidth = 350}) => {
|
||||
}}
|
||||
>
|
||||
<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={{
|
||||
width: `${LAYOUT_CONSTANTS.CANVAS_WIDTH}px`,
|
||||
height: `${LAYOUT_CONSTANTS.CANVAS_HEIGHT}px`,
|
||||
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>
|
||||
|
||||
@@ -1,57 +1,80 @@
|
||||
"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 { Download, Edit3, Loader2, Pause, Play, Type } from 'lucide-react';
|
||||
import { useEffect, useState } from '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 [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
{/* <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-12 h-12 rounded-full shadow-sm border "
|
||||
>
|
||||
<span className="text-sm font-medium ">9:16</span>
|
||||
</Button> */}
|
||||
// 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);
|
||||
}, []);
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-12 h-12 rounded-full shadow-sm border "
|
||||
>
|
||||
<Type className="h-8 w-8 " />
|
||||
</Button>
|
||||
const handlePlayPause = () => {
|
||||
if (window.timelineControls) {
|
||||
if (isPlaying) {
|
||||
window.timelineControls.pause();
|
||||
} else {
|
||||
window.timelineControls.play();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
<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>
|
||||
const handleExport = () => {
|
||||
if (window.timelineControls && !isExporting) {
|
||||
window.timelineControls.export();
|
||||
}
|
||||
};
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-12 h-12 rounded-full shadow-sm border "
|
||||
>
|
||||
<Download className="h-8 w-8 " />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center gap-2', className)}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-full border shadow-sm"
|
||||
onClick={handlePlayPause}
|
||||
disabled={!window.timelineControls}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-8 w-8" /> : <Play className="h-8 w-8" />}
|
||||
</Button>
|
||||
|
||||
<Button variant="default" size="icon" className="h-12 w-12 rounded-full border shadow-sm">
|
||||
<Type className="h-8 w-8" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
id="edit"
|
||||
variant={isEditActive ? 'default' : 'default'}
|
||||
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="default"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-full border shadow-sm"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting || !window.timelineControls}
|
||||
>
|
||||
{isExporting ? <Loader2 className="h-8 w-8 animate-spin" /> : <Download className="h-8 w-8" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorControls;
|
||||
|
||||
@@ -12,7 +12,7 @@ const EditorHeader = ({ className = '', onNavClick = () => {}, isNavActive = fal
|
||||
const [openCoinDialog, setOpenCoinDialog] = useState(false);
|
||||
|
||||
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">
|
||||
<Menu className="h-8 w-8" />
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,7 @@ import Editor from '@/modules/editor/editor';
|
||||
|
||||
const Home = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-800">
|
||||
<Editor />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user