Update
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user