This commit is contained in:
ct
2025-06-21 09:13:23 +08:00
parent db10fc3f1c
commit 8e58f85860
39 changed files with 684 additions and 181 deletions

View File

@@ -167,50 +167,52 @@ const Editor = () => {
};
return (
<div className="relative mx-auto flex min-h-screen flex-col space-y-2 py-4" style={{ width: `${responsiveWidth}px` }}>
<EditSidebar isOpen={isEditSidebarOpen} onClose={handleEditClose} />
<EditNavSidebar isOpen={isEditNavSidebarOpen} onClose={handleEditNavClose} />
<TextSidebar isOpen={isTextSidebarOpen} onClose={handleTextSidebarClose} />
<>
<div className="relative mx-auto flex min-h-screen flex-col space-y-2 py-4" style={{ width: `${responsiveWidth}px` }}>
<EditSidebar isOpen={isEditSidebarOpen} onClose={handleEditClose} />
<EditNavSidebar isOpen={isEditNavSidebarOpen} onClose={handleEditNavClose} />
<TextSidebar isOpen={isTextSidebarOpen} onClose={handleTextSidebarClose} />
<EditorHeader
className="mx-auto"
style={{ width: `${responsiveWidth}px` }}
onNavClick={handleEditNavClick}
isNavActive={isEditNavSidebarOpen}
/>
<EditorHeader
className="mx-auto"
style={{ width: `${responsiveWidth}px` }}
onNavClick={handleEditNavClick}
isNavActive={isEditNavSidebarOpen}
/>
{isBelowMinWidth ? (
<div className="aspect-[9/16]">
<div className="flex h-full flex-1 items-center justify-center rounded-lg border bg-white p-6 shadow-lg dark:bg-neutral-900">
<div className="space-y-3">
<div>
<div className="relative mb-3 flex justify-center">
<img width="180" height="180" src="https://cdn.memeaigen.com/landing/dancing-potato.gif"></img>
</div>
{isBelowMinWidth ? (
<div className="aspect-[9/16]">
<div className="flex h-full flex-1 items-center justify-center rounded-lg border bg-white p-6 shadow-lg dark:bg-neutral-900">
<div className="space-y-3">
<div>
<div className="relative mb-3 flex justify-center">
<img width="180" height="180" src="https://cdn.memeaigen.com/landing/dancing-potato.gif"></img>
</div>
<div className="w-full space-y-2 text-center">
<p className="text-muted-foreground text-sm leading-relaxed">
{getSetting('genAlphaSlang')
? 'Not gonna lie, using on a potato screen is giving L vibes. Desktop hits different - that experience is straight fire, bet!'
: 'You seem to be using a potato-sized screen. Please continue with desktop for a more refined experience!'}
</p>
<div className="w-full space-y-2 text-center">
<p className="text-muted-foreground text-sm leading-relaxed">
{getSetting('genAlphaSlang')
? 'Not gonna lie, using on a potato screen is giving L vibes. Desktop hits different - that experience is straight fire, bet!'
: 'You seem to be using a potato-sized screen. Please continue with desktop for a more refined experience!'}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
) : (
<>
<EditorCanvas maxWidth={maxWidth} onOpenTextSidebar={handleTextSidebarOpen} />
<EditorControls
className="mx-auto"
style={{ width: `${responsiveWidth}px` }}
onEditClick={handleEditClick}
isEditActive={isEditSidebarOpen}
/>
</>
)}
</div>
) : (
<>
<EditorCanvas maxWidth={maxWidth} onOpenTextSidebar={handleTextSidebarOpen} />
<EditorControls
className="mx-auto"
style={{ width: `${responsiveWidth}px` }}
onEditClick={handleEditClick}
isEditActive={isEditSidebarOpen}
/>
</>
)}
</div>
</>
);
};

View File

@@ -0,0 +1,37 @@
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Spinner } from '@/components/ui/spinner';
import { Textarea } from '@/components/ui/textarea';
const VideoDownloadModal = ({ isOpen, onClose, ffmpegCommand, handleDownloadButton, isExporting, exportProgress, exportStatus }) => {
const debug = true;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Download Video</DialogTitle>
{exportStatus ||
(exportProgress > 0 && (
<DialogDescription>
<div className="flex items-center justify-center">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium">{exportStatus}</span>
<span className="text-xs font-medium">{exportProgress}%</span>
</div>
<Spinner className={'h-5 w-5'} />
</div>
</DialogDescription>
))}
</DialogHeader>
{debug && <Textarea value={ffmpegCommand} readOnly />}
<Button onClick={handleDownloadButton}>{isExporting ? <Spinner className="text-secondary h-4 w-4" /> : 'Download'}</Button>
{/* Add your content here */}
</DialogContent>
</Dialog>
);
};
export default VideoDownloadModal;

View File

@@ -4,10 +4,13 @@ import useVideoEditorStore from '@/stores/VideoEditorStore';
import { useCallback, useEffect, useRef, useState } from 'react';
import SINGLE_CAPTION_TEMPLATE from '../../templates/single_caption_meme_background.json';
import { generateTimelineFromTemplate } from '../../utils/timeline-template-processor';
import VideoDownloadModal from './video-download/video-download-modal';
import useVideoExport from './video-export';
import VideoPreview from './video-preview';
const VideoEditor = ({ width, height, onOpenTextSidebar }) => {
const [isDownloadModalOpen, setIsDownloadModalOpen] = useState(false);
const [showConsoleLogs] = useState(true);
const [dimensions] = useState({
@@ -34,7 +37,7 @@ const VideoEditor = ({ width, height, onOpenTextSidebar }) => {
const pausedTimeRef = useRef(0);
const { setVideoIsPlaying } = useVideoEditorStore();
const { selectedMeme, selectedBackground, currentCaption } = useMediaStore();
const { selectedMeme, selectedBackground, currentCaption, watermarked } = useMediaStore();
const FPS_INTERVAL = 1000 / 30; // 30 FPS
@@ -544,9 +547,19 @@ const VideoEditor = ({ width, height, onOpenTextSidebar }) => {
});
}, [handlePause, handleSeek, videoElements]);
const handleExport = useCallback(() => {
exportVideo();
}, [exportVideo]);
const handleOpenDownloadModal = () => {
setIsDownloadModalOpen(true);
};
const activeElements = getActiveElements(currentTime);
useEffect(() => {
emitter.on('video-open-download-modal', handleOpenDownloadModal);
emitter.on('video-export', handleExport);
emitter.on('video-play', handlePlay);
emitter.on('video-reset', handleReset);
emitter.on('video-seek', handleSeek);
@@ -555,6 +568,8 @@ const VideoEditor = ({ width, height, onOpenTextSidebar }) => {
});
return () => {
emitter.off('video-open-download-modal', handleOpenDownloadModal);
emitter.off('video-export', handleExport);
emitter.off('video-play', handlePlay);
emitter.off('video-reset', handleReset);
emitter.off('video-seek', handleSeek);
@@ -563,33 +578,45 @@ const VideoEditor = ({ width, height, onOpenTextSidebar }) => {
}, [emitter, handlePlay, handleReset, handleSeek, handleElementUpdate]);
return (
<div style={{ width: dimensions.width, height: dimensions.height }} className="rounded-3xl">
<VideoPreview
dimensions={dimensions}
currentTime={currentTime}
totalDuration={totalDuration}
isPlaying={isPlaying}
status={status}
<>
<div style={{ width: dimensions.width, height: dimensions.height }} className="rounded-3xl">
<VideoPreview
watermarked={watermarked}
dimensions={dimensions}
currentTime={currentTime}
totalDuration={totalDuration}
isPlaying={isPlaying}
status={status}
isExporting={isExporting}
exportProgress={exportProgress}
exportStatus={exportStatus}
timelineElements={timelineElements}
activeElements={activeElements}
videoElements={videoElements}
loadedVideos={loadedVideos}
videoStates={videoStates}
ffmpegCommand={ffmpegCommand}
handlePlay={handlePlay}
handlePause={handlePause}
handleReset={handleReset}
handleSeek={handleSeek}
copyFFmpegCommand={copyFFmpegCommand}
exportVideo={exportVideo}
onElementUpdate={handleElementUpdate}
onOpenTextSidebar={onOpenTextSidebar}
layerRef={layerRef}
/>
</div>
<VideoDownloadModal
isOpen={isDownloadModalOpen}
onClose={() => setIsDownloadModalOpen(false)}
ffmpegCommand={ffmpegCommand}
handleDownloadButton={handleExport}
isExporting={isExporting}
exportProgress={exportProgress}
exportStatus={exportStatus}
timelineElements={timelineElements}
activeElements={activeElements}
videoElements={videoElements}
loadedVideos={loadedVideos}
videoStates={videoStates}
ffmpegCommand={ffmpegCommand}
handlePlay={handlePlay}
handlePause={handlePause}
handleReset={handleReset}
handleSeek={handleSeek}
copyFFmpegCommand={copyFFmpegCommand}
exportVideo={exportVideo}
onElementUpdate={handleElementUpdate}
onOpenTextSidebar={onOpenTextSidebar}
layerRef={layerRef}
/>
</div>
</>
);
};

View File

@@ -1,6 +1,6 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
// Font configuration mapping
const FONT_CONFIG = {
@@ -25,7 +25,7 @@ const FONT_CONFIG = {
};
const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
const [showConsoleLogs] = useState(false);
const [showConsoleLogs] = useState(true);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState(0);
@@ -50,6 +50,10 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
}
};
useEffect(() => {
console.log(JSON.stringify(timelineElements));
}, [timelineElements]);
// Helper function to convert color format for FFmpeg
const formatColorForFFmpeg = (color) => {
// Handle hex colors (e.g., #ffffff or #fff)
@@ -72,6 +76,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
const generateFFmpegCommand = useCallback(
(is_string = true, useLocalFiles = false) => {
showConsoleLogs && console.log('🎬 STARTING FFmpeg generation');
showConsoleLogs && console.log(`📐 Canvas size: ${dimensions.width}x${dimensions.height}, Duration: ${totalDuration}s`);
const videos = timelineElements.filter((el) => el.type === 'video');
const images = timelineElements.filter((el) => el.type === 'image');
@@ -81,6 +86,20 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
showConsoleLogs && console.log('Images found:', images.length);
showConsoleLogs && console.log('Texts found:', texts.length);
// Check for WebM videos with potential transparency
const webmVideos = videos.filter((v) => v.source_webm && (v.source_webm.includes('.webm') || v.source_webm.includes('webm')));
if (webmVideos.length > 0) {
showConsoleLogs && console.log(`🌟 Found ${webmVideos.length} WebM video(s) - will preserve transparency`);
}
// Summary of all elements for debugging
showConsoleLogs && console.log('📋 Element Summary:');
videos.forEach((v, i) => showConsoleLogs && console.log(` Video ${i}: Layer ${v.layer} (${v.x},${v.y}) ${v.width}x${v.height}`));
images.forEach(
(img, i) => showConsoleLogs && console.log(` Image ${i}: Layer ${img.layer} (${img.x},${img.y}) ${img.width}x${img.height}`),
);
texts.forEach((t, i) => showConsoleLogs && console.log(` Text ${i}: Layer ${t.layer} (${t.x},${t.y}) "${t.text.substring(0, 30)}..."`));
if (videos.length === 0 && images.length === 0) {
if (is_string) {
return 'ffmpeg -f lavfi -i color=black:size=450x800:duration=1 -c:v libx264 -t 1 output.mp4';
@@ -110,32 +129,139 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
filters.push(`color=black:size=${dimensions.width}x${dimensions.height}:duration=${totalDuration}[base]`);
let videoLayer = 'base';
let currentInputIndex = 0;
// Process video elements
videos.forEach((v, i) => {
filters.push(`[${currentInputIndex}:v]trim=start=${v.inPoint}: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(
`[${videoLayer}][v${i}_scale]overlay=${Math.round(v.x)}:${Math.round(v.y)}:enable='between(t,${v.startTime},${
v.startTime + v.duration
})'[v${i}_out]`,
);
videoLayer = `v${i}_out`;
currentInputIndex++;
});
// FIXED: Sort all visual elements by layer, then process in layer order
const allVisualElements = [
...videos.map((v, i) => ({ ...v, elementType: 'video', originalIndex: i })),
...images.map((img, i) => ({ ...img, elementType: 'image', originalIndex: i })),
...texts.map((t, i) => ({ ...t, elementType: 'text', originalIndex: i })),
].sort((a, b) => (a.layer || 0) - (b.layer || 0)); // Sort by layer (lowest first)
// Process image elements
images.forEach((img, i) => {
const imgInputIndex = currentInputIndex;
filters.push(`[${imgInputIndex}:v]scale=${Math.round(img.width)}:${Math.round(img.height)}[img${i}_scale]`);
filters.push(
`[${videoLayer}][img${i}_scale]overlay=${Math.round(img.x)}:${Math.round(img.y)}:enable='between(t,${img.startTime},${
img.startTime + img.duration
})'[img${i}_out]`,
showConsoleLogs &&
console.log(
'🎭 Processing order by layer:',
allVisualElements.map((el) => `${el.elementType}${el.originalIndex}(L${el.layer || 0})`).join(' → '),
);
videoLayer = `img${i}_out`;
currentInputIndex++;
// Track input indices for videos and images
let videoInputIndex = 0;
let imageInputIndex = videos.length; // Images start after videos
// Process elements in layer order
allVisualElements.forEach((element, processingIndex) => {
if (element.elementType === 'video') {
const v = element;
const i = element.originalIndex;
showConsoleLogs &&
console.log(
`🎬 Video ${i} (Layer ${v.layer || 0}) - Position: (${v.x}, ${v.y}), Size: ${v.width}x${v.height}, Time: ${v.startTime}-${v.startTime + v.duration}`,
);
// Check if video extends outside canvas
if (v.x < 0 || v.y < 0 || v.x + v.width > dimensions.width || v.y + v.height > dimensions.height) {
console.warn(`⚠️ Video ${i} extends outside canvas boundaries`);
}
// Check if this is a WebM video (likely has transparency)
const isWebM = v.source_webm && (v.source_webm.includes('.webm') || v.source_webm.includes('webm'));
filters.push(`[${videoInputIndex}:v]trim=start=${v.inPoint}:duration=${v.duration},setpts=PTS-STARTPTS[v${i}_trim]`);
// For WebM videos, preserve alpha channel during scaling
if (isWebM) {
showConsoleLogs && console.log(`🌟 Video ${i} is WebM - preserving alpha channel`);
filters.push(`[v${i}_trim]scale=${Math.round(v.width)}:${Math.round(v.height)}:flags=bicubic[v${i}_scale]`);
} else {
filters.push(`[v${i}_trim]scale=${Math.round(v.width)}:${Math.round(v.height)}[v${i}_scale]`);
}
// For overlay, ensure alpha blending is enabled for WebM
if (isWebM) {
filters.push(
`[${videoLayer}][v${i}_scale]overlay=${Math.round(v.x)}:${Math.round(v.y)}:enable='between(t,${v.startTime},${
v.startTime + v.duration
})':format=auto:eof_action=pass[v${i}_out]`,
);
} else {
filters.push(
`[${videoLayer}][v${i}_scale]overlay=${Math.round(v.x)}:${Math.round(v.y)}:enable='between(t,${v.startTime},${
v.startTime + v.duration
})'[v${i}_out]`,
);
}
videoLayer = `v${i}_out`;
videoInputIndex++;
} else if (element.elementType === 'image') {
const img = element;
const i = element.originalIndex;
showConsoleLogs &&
console.log(
`🖼️ Image ${i} (Layer ${img.layer || 0}) - Position: (${img.x}, ${img.y}), Size: ${img.width}x${img.height}, Time: ${img.startTime}-${img.startTime + img.duration}`,
);
// Check if image is larger than canvas or positioned outside
if (img.width > dimensions.width || img.height > dimensions.height) {
console.warn(`⚠️ Image ${i} (${img.width}x${img.height}) is larger than canvas (${dimensions.width}x${dimensions.height})`);
}
if (img.x < 0 || img.y < 0 || img.x + img.width > dimensions.width || img.y + img.height > dimensions.height) {
console.warn(`⚠️ Image ${i} extends outside canvas boundaries`);
}
filters.push(`[${imageInputIndex}:v]scale=${Math.round(img.width)}:${Math.round(img.height)}[img${i}_scale]`);
filters.push(
`[${videoLayer}][img${i}_scale]overlay=${Math.round(img.x)}:${Math.round(img.y)}:enable='between(t,${img.startTime},${
img.startTime + img.duration
})'[img${i}_out]`,
);
videoLayer = `img${i}_out`;
imageInputIndex++;
} else if (element.elementType === 'text') {
const t = element;
const i = element.originalIndex;
showConsoleLogs &&
console.log(`📝 Text ${i} (Layer ${t.layer || 0}) - Position: (${t.x}, ${t.y}) Text: "${t.text.substring(0, 30)}..."`);
// Better text escaping for FFmpeg
const escapedText = t.text
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/'/g, is_string ? "\\'" : "'") // Escape quotes
.replace(/:/g, '\\:') // Escape colons
.replace(/\[/g, '\\[') // Escape square brackets
.replace(/\]/g, '\\]')
.replace(/,/g, '\\,') // Escape commas
.replace(/;/g, '\\;'); // Escape semicolons
// Get the appropriate font file path
const fontFilePath = getFontFilePath(t.fontFamily, t.fontWeight, t.fontStyle);
const fontFileName = fontFilePath.split('/').pop();
// Center the text: x position is the center point, y is adjusted for baseline
const centerY = Math.round(t.y + t.fontSize * 0.3); // Adjust for text baseline
// Format colors for FFmpeg
const fontColor = formatColorForFFmpeg(t.fill);
const borderColor = formatColorForFFmpeg(t.stroke);
const borderWidth = Math.max(0, t.strokeWidth || 0); // Ensure non-negative
// Build drawtext filter with proper border handling
// For centering: use (w-tw)/2 for x and adjust y as needed
let drawTextFilter = `[${videoLayer}]drawtext=fontfile=/${fontFileName}:text='${escapedText}':x=(w-tw)/2:y=${centerY}:fontsize=${t.fontSize}:fontcolor=${fontColor}`;
// Only add border if strokeWidth > 0
if (borderWidth > 0) {
drawTextFilter += `:borderw=${borderWidth}:bordercolor=${borderColor}`;
}
drawTextFilter += `:enable='between(t,${t.startTime},${t.startTime + t.duration})'[t${i}]`;
showConsoleLogs && console.log(`Text filter ${i}:`, drawTextFilter);
filters.push(drawTextFilter);
videoLayer = `t${i}`;
}
});
showConsoleLogs && console.log('🎵 PROCESSING AUDIO FOR', videos.length, 'VIDEOS');
@@ -159,39 +285,14 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
showConsoleLogs && console.log('🎵 Audio args:', audioArgs);
// Process text elements with proper font support and color handling
texts.forEach((t, i) => {
const escapedText = t.text.replace(/'/g, is_string ? "\\'" : "'").replace(/:/g, '\\:');
// Get the appropriate font file path
const fontFilePath = getFontFilePath(t.fontFamily, t.fontWeight, t.fontStyle);
const fontFileName = fontFilePath.split('/').pop();
// Center the text: x position is the center point, y is adjusted for baseline
const centerX = Math.round(t.x);
const centerY = Math.round(t.y + t.fontSize * 0.3); // Adjust for text baseline
// Format colors for FFmpeg
const fontColor = formatColorForFFmpeg(t.fill);
const borderColor = formatColorForFFmpeg(t.stroke);
const borderWidth = Math.max(0, t.strokeWidth || 0); // Ensure non-negative
// Build drawtext filter with proper border handling
let drawTextFilter = `[${videoLayer}]drawtext=fontfile=/${fontFileName}:text='${escapedText}':x=${centerX}:y=${centerY}:fontsize=${t.fontSize}:fontcolor=${fontColor}`;
// Only add border if strokeWidth > 0
if (borderWidth > 0) {
drawTextFilter += `:borderw=${borderWidth}:bordercolor=${borderColor}`;
}
drawTextFilter += `:text_align=center:enable='between(t,${t.startTime},${t.startTime + t.duration})'[t${i}]`;
filters.push(drawTextFilter);
videoLayer = `t${i}`;
});
const filterComplex = filters.join('; ');
showConsoleLogs && console.log('🎵 Filter includes atrim:', filterComplex.includes('atrim'));
showConsoleLogs && console.log('📝 Complete filter complex:', filterComplex);
showConsoleLogs &&
console.log(
`🎭 Final layer order:`,
allVisualElements.map((el) => `${el.elementType}${el.originalIndex}(L${el.layer || 0})`).join(' → '),
);
const finalArgs = [
...inputArgs,
@@ -263,7 +364,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
});
ffmpeg.on('log', ({ message }) => {
showConsoleLogs && console.log(message);
showConsoleLogs && console.log('FFmpeg Log:', message);
});
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
@@ -288,44 +389,43 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
setExportStatus('Loading fonts...');
// Load all required fonts
const fontsToLoad = new Set();
// Collect all fonts that need to be loaded with their correct paths
const fontsToLoad = new Map(); // Map from filename to full path
// Add Arial font (fallback)
fontsToLoad.add('arial.ttf');
fontsToLoad.set('arial.ttf', 'https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf');
// Add fonts used by text elements
// Add fonts used by text elements - FIXED: use the actual font paths from getFontFilePath
timelineElements
.filter((el) => el.type === 'text')
.forEach((text) => {
const fontFilePath = getFontFilePath(text.fontFamily, text.fontWeight, text.fontStyle);
const fontFileName = fontFilePath.split('/').pop();
fontsToLoad.add(fontFileName);
// Only add if not already in map and not arial.ttf
if (fontFileName !== 'arial.ttf' && !fontsToLoad.has(fontFileName)) {
fontsToLoad.set(fontFileName, fontFilePath); // Use the actual path, not reconstructed
}
});
showConsoleLogs && console.log('Fonts to load:', Array.from(fontsToLoad.entries()));
// Load each unique font
let fontProgress = 0;
for (const fontFile of fontsToLoad) {
for (const [fontFileName, fontPath] of fontsToLoad) {
try {
if (fontFile === 'arial.ttf') {
await ffmpeg.writeFile(
'arial.ttf',
await fetchFile('https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf'),
);
} else {
// Load Montserrat fonts from local filesystem
const fontPath = `/fonts/Montserrat/static/${fontFile}`;
await ffmpeg.writeFile(fontFile, await fetchFile(fontPath));
}
showConsoleLogs && console.log(`Loading font: ${fontFileName} from ${fontPath}`);
await ffmpeg.writeFile(fontFileName, await fetchFile(fontPath));
showConsoleLogs && console.log(`✓ Font ${fontFileName} loaded successfully`);
fontProgress++;
setExportProgress(10 + Math.round((fontProgress / fontsToLoad.size) * 10));
} catch (error) {
console.warn(`Failed to load font ${fontFile}, falling back to arial.ttf:`, error);
console.error(`Failed to load font ${fontFileName} from ${fontPath}:`, error);
// If font loading fails, we'll use arial.ttf as fallback
}
}
showConsoleLogs && console.log('Fonts loaded!');
showConsoleLogs && console.log('All fonts loaded!');
setExportProgress(20);
setExportStatus('Downloading media...');
@@ -333,27 +433,69 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
const images = timelineElements.filter((el) => el.type === 'image');
const totalMedia = videos.length + images.length;
showConsoleLogs && console.log(`Total media to download: ${totalMedia} (${videos.length} videos, ${images.length} images)`);
let mediaProgress = 0;
// Download videos
for (let i = 0; i < videos.length; i++) {
await ffmpeg.writeFile(`input_video_${i}.webm`, await fetchFile(videos[i].source_webm));
try {
showConsoleLogs && console.log(`Downloading video ${i}: ${videos[i].source_webm}`);
await ffmpeg.writeFile(`input_video_${i}.webm`, await fetchFile(videos[i].source_webm));
showConsoleLogs && console.log(`✓ Video ${i} downloaded`);
} catch (error) {
console.error(`❌ Failed to download video ${i}:`, error);
throw new Error(`Failed to download video ${i}: ${error.message}`);
}
mediaProgress++;
setExportProgress(20 + Math.round((mediaProgress / totalMedia) * 40));
}
// Download images
for (let i = 0; i < images.length; i++) {
await ffmpeg.writeFile(`input_image_${i}.jpg`, await fetchFile(images[i].source));
try {
showConsoleLogs && console.log(`Downloading image ${i}: ${images[i].source}`);
await ffmpeg.writeFile(`input_image_${i}.jpg`, await fetchFile(images[i].source));
showConsoleLogs && console.log(`✓ Image ${i} downloaded`);
} catch (error) {
console.error(`❌ Failed to download image ${i}:`, error);
throw new Error(`Failed to download image ${i}: ${error.message}`);
}
mediaProgress++;
setExportProgress(20 + Math.round((mediaProgress / totalMedia) * 40));
}
showConsoleLogs && console.log('All media downloaded successfully!');
// List all files in FFmpeg filesystem for debugging
try {
const files = await ffmpeg.listDir('/');
showConsoleLogs && console.log('Files in FFmpeg filesystem:', files);
} catch (listError) {
console.warn('Could not list FFmpeg filesystem:', listError);
}
setExportStatus('Processing video...');
let args = generateFFmpegCommand(false, true);
showConsoleLogs && console.log('Generated FFmpeg arguments:', args);
setExportProgress(70);
await ffmpeg.exec(args);
try {
await ffmpeg.exec(args);
showConsoleLogs && console.log('FFmpeg execution completed successfully!');
} catch (execError) {
console.error('FFmpeg execution failed:', execError);
console.error('Failed arguments:', args);
// Log the specific error details
if (execError.message) {
console.error('Error message:', execError.message);
}
throw new Error(`FFmpeg execution failed: ${execError.message || 'Unknown error'}`);
}
setExportStatus('Downloading...');
setExportProgress(90);
@@ -375,7 +517,15 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
ffmpeg.terminate();
} catch (error) {
console.error('Export error:', error);
console.error('Full export error details:', {
message: error.message,
stack: error.stack,
name: error.name,
code: error.code,
errno: error.errno,
path: error.path,
error: error,
});
setExportStatus(`Failed: ${error.message}`);
} finally {
setTimeout(() => {

View File

@@ -12,6 +12,7 @@ import { useElementTransform } from './video-preview/video-preview-element-trans
import { getImageSource, getTextFontStyle } from './video-preview/video-preview-utils';
const VideoPreview = ({
watermarked,
// Dimensions
dimensions,
@@ -242,6 +243,27 @@ const VideoPreview = ({
return null;
})}
{/* Watermark - only show when watermarked is true */}
{watermarked && (
<Text
text="MEMEAIGEN.COM"
x={dimensions.width / 2}
y={dimensions.height / 2 + dimensions.height * 0.2}
fontSize={20}
fontFamily="Bungee"
fill="white"
stroke="black"
strokeWidth={2}
opacity={0.5}
align="center"
verticalAlign="middle"
offsetX={90} // Approximate half-width to center the text
offsetY={5} // Approximate half-height to center the text
draggable={false}
listening={false} // Prevents any mouse interactions
/>
)}
{/* Guide Lines Layer */}
{guideLines.showVertical && (
<Line

View File

@@ -18,6 +18,10 @@ const EditorControls = ({ className = '', onEditClick = () => {}, isEditActive =
emitter.emit('video-reset');
};
const handleDownloadButton = () => {
emitter.emit('video-open-download-modal');
};
const togglePlayPause = () => {
if (videoIsPlaying) {
handleReset();
@@ -50,7 +54,7 @@ const EditorControls = ({ className = '', onEditClick = () => {}, isEditActive =
<Edit3 className={`h-8 w-8 ${isEditActive ? 'text-white' : ''}`} />
</Button>
<Button variant="outline" size="icon" className="h-12 w-12 rounded-full border shadow-sm">
<Button onClick={handleDownloadButton} variant="outline" size="icon" className="h-12 w-12 rounded-full border shadow-sm">
<Download className="h-8 w-8" />
</Button>