This commit is contained in:
ct
2025-06-14 11:25:13 +08:00
parent 8a732b3c31
commit 639fe165aa
11 changed files with 2144 additions and 798 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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