Update
This commit is contained in:
20
package-lock.json
generated
20
package-lock.json
generated
@@ -7,6 +7,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||||
"@ffmpeg/util": "^0.12.2",
|
"@ffmpeg/util": "^0.12.2",
|
||||||
|
"@fontsource/bungee": "^5.2.6",
|
||||||
|
"@fontsource/montserrat": "^5.2.6",
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@inertiajs/react": "^2.0.0",
|
"@inertiajs/react": "^2.0.0",
|
||||||
@@ -1033,6 +1035,24 @@
|
|||||||
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/bungee": {
|
||||||
|
"version": "5.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/bungee/-/bungee-5.2.6.tgz",
|
||||||
|
"integrity": "sha512-bA0VFjqjCs0iJ0F5Shvo3AemOp/JoYDefOqo/OPPoDjx18oUfUMI3w5kRFsCmpOX1izpCeHnGnK0LcflO1ztqQ==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/montserrat": {
|
||||||
|
"version": "5.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.2.6.tgz",
|
||||||
|
"integrity": "sha512-AfFxq1q5tgkOsjQfMFsh95uMXh39VbGOuBLlHLFg16/txv93lqK7Sr6jev0neuJzZy1kqRG16SH6xpUYnk4cZg==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@headlessui/react": {
|
"node_modules/@headlessui/react": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.4.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||||
"@ffmpeg/util": "^0.12.2",
|
"@ffmpeg/util": "^0.12.2",
|
||||||
|
"@fontsource/bungee": "^5.2.6",
|
||||||
|
"@fontsource/montserrat": "^5.2.6",
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@inertiajs/react": "^2.0.0",
|
"@inertiajs/react": "^2.0.0",
|
||||||
|
|||||||
45
resources/js/file_explanations.md
Normal file
45
resources/js/file_explanations.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# File Descriptions
|
||||||
|
|
||||||
|
## Core Video Editor Components
|
||||||
|
|
||||||
|
**video-export.jsx** - Custom React hook that handles video export functionality using FFmpeg in the browser. Manages font loading, media downloading, FFmpeg command generation, and provides export progress tracking for converting timeline elements into a final MP4 video.
|
||||||
|
|
||||||
|
**video-editor.jsx** - Main orchestrator component that manages the video editing timeline, synchronizes multiple video elements, handles playback controls, and coordinates between all child components. Contains the core logic for video loading, timing calculations, and state management.
|
||||||
|
|
||||||
|
**video-preview.jsx** - Renders the actual video preview using React Konva for canvas-based rendering. Displays active timeline elements (videos, images, text) with interactive selection, transformation handles, and guide lines for precise positioning.
|
||||||
|
|
||||||
|
## Video Preview Utilities
|
||||||
|
|
||||||
|
**video-preview-utils.js** - Utility functions for video preview operations including position snapping calculations, element bounds detection, font style formatting, and center-positioning logic for different element types.
|
||||||
|
|
||||||
|
**video-preview-element-selection.js** - Custom hook that manages element selection state in the preview canvas, handles click events for selecting/deselecting elements, and emits events when text elements are selected for editing.
|
||||||
|
|
||||||
|
**video-preview-element-transform.js** - Custom hook that handles element transformations including drag operations with snapping, resize/scale operations, rotation, and visual guide line management for precise element positioning.
|
||||||
|
|
||||||
|
## UI Components
|
||||||
|
|
||||||
|
**video-download-modal.jsx** - Modal dialog component for video export that displays download progress, export status, and optionally shows the generated FFmpeg command for debugging purposes.
|
||||||
|
|
||||||
|
**editor-controls.jsx** - Bottom control bar with play/pause, edit, download, and share buttons. Uses event emitters to communicate with the video editor for playback control.
|
||||||
|
|
||||||
|
**text-sidebar.jsx** - Right sidebar for editing text element properties including content, font family, size, weight, style, colors, and stroke width. Provides real-time preview and updates the selected text element.
|
||||||
|
|
||||||
|
**edit-sidebar.jsx** - Right sidebar with tabs for selecting memes and backgrounds from media libraries. Handles fetching, displaying, and selecting media assets with visual previews.
|
||||||
|
|
||||||
|
**edit-nav-sidebar.jsx** - Left navigation sidebar containing settings dialog and app branding. Includes toggles for application preferences like "gen alpha slang" mode.
|
||||||
|
|
||||||
|
**editor-header.jsx** - Top header bar with navigation menu button, app title, and coin display. Includes a dialog for upcoming AI features with contextual messaging based on settings.
|
||||||
|
|
||||||
|
**editor-canvas.jsx** - Responsive wrapper component that scales the video editor canvas based on viewport size while maintaining the 9:16 aspect ratio and handling coordinate transformations.
|
||||||
|
|
||||||
|
**editor.jsx** - Main application component that coordinates all sidebars, handles responsive layout, manages global state, and provides the overall editor interface structure.
|
||||||
|
|
||||||
|
## Data and Utilities
|
||||||
|
|
||||||
|
**sample-timeline-data.jsx** - Static sample data containing timeline elements (videos, images, text) with positioning, timing, and media source information for testing and demonstration purposes.
|
||||||
|
|
||||||
|
**video-editor-commentary.jsx** - Comprehensive documentation file explaining the video editor's architecture, system flow, performance optimizations, and key design decisions with detailed technical commentary.
|
||||||
|
|
||||||
|
**timeline-template-processor.js** - Utility function that processes timeline templates by combining them with selected media assets (memes, backgrounds, captions) to generate complete timeline configurations.
|
||||||
|
|
||||||
|
**layout-constants.js** - Defines responsive layout constants and calculation functions for optimal canvas sizing, viewport detection, and maintaining proper aspect ratios across different screen sizes.
|
||||||
@@ -4,6 +4,10 @@ import { useMitt } from '@/plugins/MittContext';
|
|||||||
import useLocalSettingsStore from '@/stores/localSettingsStore';
|
import useLocalSettingsStore from '@/stores/localSettingsStore';
|
||||||
import useMediaStore from '@/stores/MediaStore';
|
import useMediaStore from '@/stores/MediaStore';
|
||||||
import useVideoEditorStore from '@/stores/VideoEditorStore';
|
import useVideoEditorStore from '@/stores/VideoEditorStore';
|
||||||
|
|
||||||
|
// Import fonts first - this loads all Fontsource packages
|
||||||
|
import '@/modules/editor/fonts';
|
||||||
|
|
||||||
import EditNavSidebar from './partials/edit-nav-sidebar';
|
import EditNavSidebar from './partials/edit-nav-sidebar';
|
||||||
import EditSidebar from './partials/edit-sidebar';
|
import EditSidebar from './partials/edit-sidebar';
|
||||||
import EditorCanvas from './partials/editor-canvas';
|
import EditorCanvas from './partials/editor-canvas';
|
||||||
|
|||||||
297
resources/js/modules/editor/fonts.jsx
Normal file
297
resources/js/modules/editor/fonts.jsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
// fonts.jsx - Centralized Font Management System
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// FONT IMPORTS - All Fontsource imports in one place
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Montserrat - Modern sans-serif, great for UI text
|
||||||
|
import '@fontsource/montserrat/400-italic.css'; // Italic
|
||||||
|
import '@fontsource/montserrat/400.css'; // Normal
|
||||||
|
import '@fontsource/montserrat/700-italic.css'; // Bold Italic
|
||||||
|
import '@fontsource/montserrat/700.css'; // Bold
|
||||||
|
|
||||||
|
// Bungee - Display font for headers and watermarks
|
||||||
|
import '@fontsource/bungee/400.css';
|
||||||
|
|
||||||
|
// Optional: Add more fonts here as needed
|
||||||
|
// import '@fontsource/inter/400.css';
|
||||||
|
// import '@fontsource/roboto/400.css';
|
||||||
|
|
||||||
|
// NOTE: Make sure to install these packages:
|
||||||
|
// npm install @fontsource/montserrat @fontsource/bungee
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// FONT CONFIGURATION - Single source of truth for all font data
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const FONTS = {
|
||||||
|
montserrat: {
|
||||||
|
name: 'Montserrat',
|
||||||
|
family: 'Montserrat',
|
||||||
|
category: 'sans-serif',
|
||||||
|
weights: [400, 700],
|
||||||
|
styles: ['normal', 'italic'],
|
||||||
|
description: 'Modern geometric sans-serif',
|
||||||
|
preview: 'The quick brown fox jumps over the lazy dog',
|
||||||
|
},
|
||||||
|
bungee: {
|
||||||
|
name: 'Bungee',
|
||||||
|
family: 'Bungee',
|
||||||
|
category: 'display',
|
||||||
|
weights: [400],
|
||||||
|
styles: ['normal'],
|
||||||
|
description: 'Decorative display font',
|
||||||
|
preview: 'MEMEAIGEN.COM',
|
||||||
|
},
|
||||||
|
arial: {
|
||||||
|
name: 'Arial',
|
||||||
|
family: 'Arial',
|
||||||
|
category: 'system',
|
||||||
|
weights: [400, 700],
|
||||||
|
styles: ['normal', 'italic'],
|
||||||
|
description: 'System fallback font',
|
||||||
|
preview: 'System default font',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// FONT UTILITIES - Helper functions for font operations
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available fonts as array for UI components
|
||||||
|
* @returns {Array} Array of font objects for dropdowns/selectors
|
||||||
|
*/
|
||||||
|
const getAvailableFonts = () => {
|
||||||
|
return Object.values(FONTS).map((font) => ({
|
||||||
|
name: font.name,
|
||||||
|
value: font.family,
|
||||||
|
category: font.category,
|
||||||
|
description: font.description,
|
||||||
|
preview: font.preview,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get font family name by key
|
||||||
|
* @param {string} fontKey - Key from FONTS object
|
||||||
|
* @returns {string} Font family name
|
||||||
|
*/
|
||||||
|
const getFontFamily = (fontKey) => {
|
||||||
|
return FONTS[fontKey]?.family || FONTS.montserrat.family;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CSS font-style value based on element properties
|
||||||
|
* @param {Object} element - Text element with font properties
|
||||||
|
* @returns {string} CSS font-style value
|
||||||
|
*/
|
||||||
|
const getFontStyle = (element) => {
|
||||||
|
const isBold = element.fontWeight === 'bold' || element.fontWeight === 700;
|
||||||
|
const isItalic = element.fontStyle === 'italic';
|
||||||
|
|
||||||
|
if (isBold && isItalic) {
|
||||||
|
return 'bold italic';
|
||||||
|
} else if (isBold) {
|
||||||
|
return 'bold';
|
||||||
|
} else if (isItalic) {
|
||||||
|
return 'italic';
|
||||||
|
} else {
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get numeric font weight
|
||||||
|
* @param {string|number} fontWeight - Font weight value
|
||||||
|
* @returns {number} Numeric font weight
|
||||||
|
*/
|
||||||
|
const getFontWeight = (fontWeight) => {
|
||||||
|
if (fontWeight === 'bold') return 700;
|
||||||
|
if (typeof fontWeight === 'number') return fontWeight;
|
||||||
|
return 400; // default normal
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if font supports specific weight/style combination
|
||||||
|
* @param {string} fontFamily - Font family name
|
||||||
|
* @param {string|number} fontWeight - Font weight
|
||||||
|
* @param {string} fontStyle - Font style
|
||||||
|
* @returns {boolean} Whether combination is supported
|
||||||
|
*/
|
||||||
|
const isFontCombinationSupported = (fontFamily, fontWeight, fontStyle) => {
|
||||||
|
const font = Object.values(FONTS).find((f) => f.family === fontFamily);
|
||||||
|
if (!font) return false;
|
||||||
|
|
||||||
|
const numericWeight = getFontWeight(fontWeight);
|
||||||
|
const hasWeight = font.weights.includes(numericWeight);
|
||||||
|
const hasStyle = font.styles.includes(fontStyle || 'normal');
|
||||||
|
|
||||||
|
return hasWeight && hasStyle;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fallback font if current combination isn't supported
|
||||||
|
* @param {string} fontFamily - Desired font family
|
||||||
|
* @param {string|number} fontWeight - Desired font weight
|
||||||
|
* @param {string} fontStyle - Desired font style
|
||||||
|
* @returns {Object} Safe font configuration
|
||||||
|
*/
|
||||||
|
const getSafeFontConfig = (fontFamily, fontWeight, fontStyle) => {
|
||||||
|
if (isFontCombinationSupported(fontFamily, fontWeight, fontStyle)) {
|
||||||
|
return {
|
||||||
|
fontFamily,
|
||||||
|
fontWeight: getFontWeight(fontWeight),
|
||||||
|
fontStyle: fontStyle || 'normal',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Montserrat normal
|
||||||
|
return {
|
||||||
|
fontFamily: FONTS.montserrat.family,
|
||||||
|
fontWeight: 400,
|
||||||
|
fontStyle: 'normal',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// FONT LOADING UTILITIES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure specific fonts are loaded before use
|
||||||
|
* @param {Array} fontSpecs - Array of font specifications
|
||||||
|
* @returns {Promise} Promise that resolves when fonts are loaded
|
||||||
|
*/
|
||||||
|
const loadFonts = async (fontSpecs = []) => {
|
||||||
|
if (!('fonts' in document)) {
|
||||||
|
console.warn('Font Loading API not supported');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontPromises = fontSpecs.map(({ fontFamily, fontWeight, fontStyle, fontSize = 16 }) => {
|
||||||
|
const weight = getFontWeight(fontWeight);
|
||||||
|
const style = fontStyle || 'normal';
|
||||||
|
const fontSpec = `${weight} ${style} ${fontSize}px "${fontFamily}"`;
|
||||||
|
|
||||||
|
return document.fonts.load(fontSpec).catch((err) => {
|
||||||
|
console.warn(`Failed to load font: ${fontSpec}`, err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(fontPromises);
|
||||||
|
|
||||||
|
// Allow fonts to settle
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load fonts used in timeline elements
|
||||||
|
* @param {Array} timelineElements - Array of timeline elements
|
||||||
|
* @returns {Promise} Promise that resolves when all fonts are loaded
|
||||||
|
*/
|
||||||
|
const loadTimelineFonts = async (timelineElements = []) => {
|
||||||
|
const fontSpecs = new Set();
|
||||||
|
|
||||||
|
// Collect text element fonts
|
||||||
|
timelineElements
|
||||||
|
.filter((el) => el.type === 'text')
|
||||||
|
.forEach((text) => {
|
||||||
|
const fontFamily = text.fontFamily || FONTS.montserrat.family;
|
||||||
|
const fontWeight = text.fontWeight;
|
||||||
|
const fontStyle = text.fontStyle;
|
||||||
|
|
||||||
|
fontSpecs.add(
|
||||||
|
JSON.stringify({
|
||||||
|
fontFamily,
|
||||||
|
fontWeight: getFontWeight(fontWeight),
|
||||||
|
fontStyle: fontStyle || 'normal',
|
||||||
|
fontSize: Math.max(text.fontSize || 16, 16), // Ensure minimum size for loading
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add watermark font (Bungee)
|
||||||
|
fontSpecs.add(
|
||||||
|
JSON.stringify({
|
||||||
|
fontFamily: FONTS.bungee.family,
|
||||||
|
fontWeight: 400,
|
||||||
|
fontStyle: 'normal',
|
||||||
|
fontSize: 20,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert back to objects and load
|
||||||
|
const uniqueFontSpecs = Array.from(fontSpecs).map((spec) => JSON.parse(spec));
|
||||||
|
await loadFonts(uniqueFontSpecs);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PRESET CONFIGURATIONS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default text element configuration
|
||||||
|
*/
|
||||||
|
const DEFAULT_TEXT_CONFIG = {
|
||||||
|
fontFamily: FONTS.montserrat.family,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700, // Bold by default
|
||||||
|
fontStyle: 'normal',
|
||||||
|
fill: '#ffffff',
|
||||||
|
stroke: '#000000',
|
||||||
|
strokeWidth: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watermark configuration
|
||||||
|
*/
|
||||||
|
const WATERMARK_CONFIG = {
|
||||||
|
fontFamily: FONTS.bungee.family,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 400,
|
||||||
|
fontStyle: 'normal',
|
||||||
|
fill: 'white',
|
||||||
|
stroke: 'black',
|
||||||
|
strokeWidth: 2,
|
||||||
|
opacity: 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// EXPORTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// EXPORTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Named exports for individual functions
|
||||||
|
export {
|
||||||
|
DEFAULT_TEXT_CONFIG,
|
||||||
|
FONTS,
|
||||||
|
WATERMARK_CONFIG,
|
||||||
|
getAvailableFonts,
|
||||||
|
getFontFamily,
|
||||||
|
getFontStyle,
|
||||||
|
getFontWeight,
|
||||||
|
getSafeFontConfig,
|
||||||
|
isFontCombinationSupported,
|
||||||
|
loadFonts,
|
||||||
|
loadTimelineFonts,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default export with all functions grouped
|
||||||
|
export default {
|
||||||
|
FONTS,
|
||||||
|
getAvailableFonts,
|
||||||
|
getFontFamily,
|
||||||
|
getFontStyle,
|
||||||
|
getFontWeight,
|
||||||
|
isFontCombinationSupported,
|
||||||
|
getSafeFontConfig,
|
||||||
|
loadFonts,
|
||||||
|
loadTimelineFonts,
|
||||||
|
DEFAULT_TEXT_CONFIG,
|
||||||
|
WATERMARK_CONFIG,
|
||||||
|
};
|
||||||
@@ -57,7 +57,7 @@ const sampleTimelineElements = [
|
|||||||
startTime: 0,
|
startTime: 0,
|
||||||
layer: 2,
|
layer: 2,
|
||||||
duration: 4,
|
duration: 4,
|
||||||
x: 90,
|
x: 360, // Center horizontally (720/2)
|
||||||
y: 180,
|
y: 180,
|
||||||
fontSize: 40,
|
fontSize: 40,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
@@ -67,6 +67,9 @@ const sampleTimelineElements = [
|
|||||||
stroke: '#000000',
|
stroke: '#000000',
|
||||||
strokeWidth: 3,
|
strokeWidth: 3,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
|
// Add text width properties for consistent rendering
|
||||||
|
fixedWidth: 576, // 80% of 720px canvas width
|
||||||
|
offsetX: 288, // Half of fixedWidth for center alignment
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '5',
|
id: '5',
|
||||||
@@ -75,7 +78,7 @@ const sampleTimelineElements = [
|
|||||||
startTime: 3,
|
startTime: 3,
|
||||||
layer: 3,
|
layer: 3,
|
||||||
duration: 4,
|
duration: 4,
|
||||||
x: 50,
|
x: 360, // Center horizontally (720/2)
|
||||||
y: 650,
|
y: 650,
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
@@ -85,6 +88,9 @@ const sampleTimelineElements = [
|
|||||||
stroke: '#ff0000',
|
stroke: '#ff0000',
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
|
// Add text width properties for consistent rendering
|
||||||
|
fixedWidth: 576, // 80% of 720px canvas width
|
||||||
|
offsetX: 288, // Half of fixedWidth for center alignment
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '6',
|
id: '6',
|
||||||
|
|||||||
@@ -1,28 +1,10 @@
|
|||||||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||||
import { fetchFile, toBlobURL } from '@ffmpeg/util';
|
import { fetchFile, toBlobURL } from '@ffmpeg/util';
|
||||||
|
import Konva from 'konva';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
// Font configuration mapping
|
// Import centralized font management
|
||||||
const FONT_CONFIG = {
|
import { getFontStyle, loadTimelineFonts, WATERMARK_CONFIG } from '@/modules/editor/fonts';
|
||||||
Montserrat: {
|
|
||||||
normal: '/fonts/Montserrat/static/Montserrat-Regular.ttf',
|
|
||||||
bold: '/fonts/Montserrat/static/Montserrat-Bold.ttf',
|
|
||||||
italic: '/fonts/Montserrat/static/Montserrat-Italic.ttf',
|
|
||||||
boldItalic: '/fonts/Montserrat/static/Montserrat-BoldItalic.ttf',
|
|
||||||
},
|
|
||||||
Arial: {
|
|
||||||
normal: '/arial.ttf',
|
|
||||||
bold: '/arial.ttf',
|
|
||||||
italic: '/arial.ttf',
|
|
||||||
boldItalic: '/arial.ttf',
|
|
||||||
},
|
|
||||||
Bungee: {
|
|
||||||
normal: '/fonts/Bungee/Bungee-Regular.ttf',
|
|
||||||
bold: '/fonts/Bungee/Bungee-Regular.ttf',
|
|
||||||
italic: '/fonts/Bungee/Bungee-Regular.ttf',
|
|
||||||
boldItalic: '/fonts/Bungee/Bungee-Regular.ttf',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
||||||
const [showConsoleLogs] = useState(true);
|
const [showConsoleLogs] = useState(true);
|
||||||
@@ -31,25 +13,6 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
const [exportProgress, setExportProgress] = useState(0);
|
const [exportProgress, setExportProgress] = useState(0);
|
||||||
const [exportStatus, setExportStatus] = useState('');
|
const [exportStatus, setExportStatus] = useState('');
|
||||||
|
|
||||||
// Helper function to get font file path based on font family and style
|
|
||||||
const getFontFilePath = (fontFamily, fontWeight, fontStyle) => {
|
|
||||||
const family = fontFamily || 'Arial';
|
|
||||||
const config = FONT_CONFIG[family] || FONT_CONFIG.Arial;
|
|
||||||
|
|
||||||
const isBold = fontWeight === 'bold' || fontWeight === 700;
|
|
||||||
const isItalic = fontStyle === 'italic';
|
|
||||||
|
|
||||||
if (isBold && isItalic) {
|
|
||||||
return config.boldItalic;
|
|
||||||
} else if (isBold) {
|
|
||||||
return config.bold;
|
|
||||||
} else if (isItalic) {
|
|
||||||
return config.italic;
|
|
||||||
} else {
|
|
||||||
return config.normal;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(JSON.stringify(timelineElements));
|
console.log(JSON.stringify(timelineElements));
|
||||||
}, [timelineElements]);
|
}, [timelineElements]);
|
||||||
@@ -82,24 +45,202 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
return arg;
|
return arg;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Better text escaping function for FFmpeg drawtext filter
|
// Ensure text element has proper width properties for consistent rendering
|
||||||
const escapeTextForDrawtext = (text) => {
|
const ensureTextProperties = async (textElement, dimensions) => {
|
||||||
return text
|
// If fixedWidth and offsetX are already set, use them
|
||||||
.replace(/\\/g, '\\\\\\\\') // Escape backslashes - needs 4 backslashes for proper escaping
|
if (textElement.fixedWidth !== undefined && textElement.offsetX !== undefined) {
|
||||||
.replace(/'/g, "\\'") // Escape single quotes
|
return textElement;
|
||||||
.replace(/:/g, '\\:') // Escape colons
|
}
|
||||||
.replace(/\[/g, '\\[') // Escape square brackets
|
|
||||||
.replace(/\]/g, '\\]')
|
// Create a temporary stage to measure text dimensions
|
||||||
.replace(/,/g, '\\,') // Escape commas
|
const tempStage = new Konva.Stage({
|
||||||
.replace(/;/g, '\\;') // Escape semicolons
|
container: document.createElement('div'),
|
||||||
.replace(/\|/g, '\\|') // Escape pipes
|
width: dimensions.width,
|
||||||
.replace(/\n/g, ' ') // Replace newlines with spaces
|
height: dimensions.height,
|
||||||
.replace(/\r/g, ' ') // Replace carriage returns with spaces
|
});
|
||||||
.replace(/\t/g, ' '); // Replace tabs with spaces
|
|
||||||
|
const tempLayer = new Konva.Layer();
|
||||||
|
tempStage.add(tempLayer);
|
||||||
|
|
||||||
|
// Create temporary text node to measure
|
||||||
|
const tempTextNode = new Konva.Text({
|
||||||
|
text: textElement.text,
|
||||||
|
fontSize: textElement.fontSize,
|
||||||
|
fontStyle: getFontStyle(textElement),
|
||||||
|
fontFamily: textElement.fontFamily || 'Montserrat',
|
||||||
|
align: 'center',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
wrap: 'word',
|
||||||
|
// Use a reasonable width for text wrapping (80% of canvas width)
|
||||||
|
width: dimensions.width * 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
tempLayer.add(tempTextNode);
|
||||||
|
tempTextNode._setTextData();
|
||||||
|
|
||||||
|
// Get measured dimensions
|
||||||
|
const measuredWidth = tempTextNode.width();
|
||||||
|
const measuredTextWidth = tempTextNode.textWidth;
|
||||||
|
|
||||||
|
// Calculate offsetX for center alignment
|
||||||
|
const offsetX = measuredWidth / 2;
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
tempStage.destroy();
|
||||||
|
|
||||||
|
// Return element with calculated properties
|
||||||
|
return {
|
||||||
|
...textElement,
|
||||||
|
fixedWidth: measuredWidth,
|
||||||
|
offsetX: offsetX,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug function to compare preview vs export text properties
|
||||||
|
const debugTextProperties = (textElement) => {
|
||||||
|
if (showConsoleLogs) {
|
||||||
|
console.log('🔍 Text Element Properties for Export:');
|
||||||
|
console.log(' text:', textElement.text);
|
||||||
|
console.log(' x:', textElement.x);
|
||||||
|
console.log(' y:', textElement.y);
|
||||||
|
console.log(' fontSize:', textElement.fontSize);
|
||||||
|
console.log(' fontFamily:', textElement.fontFamily);
|
||||||
|
console.log(' fontWeight:', textElement.fontWeight);
|
||||||
|
console.log(' fontStyle:', textElement.fontStyle);
|
||||||
|
console.log(' width (fixedWidth):', textElement.fixedWidth);
|
||||||
|
console.log(' offsetX:', textElement.offsetX);
|
||||||
|
console.log(' rotation:', textElement.rotation);
|
||||||
|
console.log(' fill:', textElement.fill);
|
||||||
|
console.log(' stroke:', textElement.stroke);
|
||||||
|
console.log(' strokeWidth:', textElement.strokeWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render text element to image using Konva
|
||||||
|
const renderTextElementToImage = async (textElement, dimensions) => {
|
||||||
|
showConsoleLogs && console.log(`🎨 Rendering text element: "${textElement.text.substring(0, 30)}..."`);
|
||||||
|
|
||||||
|
// Ensure text element has proper width properties
|
||||||
|
const processedTextElement = await ensureTextProperties(textElement, dimensions);
|
||||||
|
|
||||||
|
// Debug text properties
|
||||||
|
debugTextProperties(processedTextElement);
|
||||||
|
|
||||||
|
// Create offscreen stage with canvas dimensions
|
||||||
|
const stage = new Konva.Stage({
|
||||||
|
container: document.createElement('div'),
|
||||||
|
width: dimensions.width,
|
||||||
|
height: dimensions.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
const layer = new Konva.Layer();
|
||||||
|
stage.add(layer);
|
||||||
|
|
||||||
|
// Wait a bit for fonts to be ready (same as preview)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// Create text node with EXACT same properties as preview
|
||||||
|
const textNode = new Konva.Text({
|
||||||
|
text: processedTextElement.text,
|
||||||
|
x: processedTextElement.x,
|
||||||
|
y: processedTextElement.y,
|
||||||
|
fontSize: processedTextElement.fontSize,
|
||||||
|
fontStyle: getFontStyle(processedTextElement),
|
||||||
|
fontFamily: processedTextElement.fontFamily || 'Montserrat',
|
||||||
|
fill: processedTextElement.fill || '#ffffff',
|
||||||
|
stroke: processedTextElement.strokeWidth > 0 ? processedTextElement.stroke || '#000000' : undefined,
|
||||||
|
strokeWidth: processedTextElement.strokeWidth * 3 || 0,
|
||||||
|
fillAfterStrokeEnabled: true,
|
||||||
|
strokeScaleEnabled: false,
|
||||||
|
rotation: processedTextElement.rotation || 0,
|
||||||
|
// EXACT same alignment as preview
|
||||||
|
align: 'center',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
wrap: 'word',
|
||||||
|
// EXACT same scaling as preview
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
// EXACT same width/offset as preview
|
||||||
|
width: processedTextElement.fixedWidth,
|
||||||
|
offsetX: processedTextElement.offsetX,
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.add(textNode);
|
||||||
|
|
||||||
|
// Force text measurement like in preview
|
||||||
|
textNode._setTextData();
|
||||||
|
layer.draw();
|
||||||
|
|
||||||
|
// Wait for rendering to complete
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Log text dimensions for debugging
|
||||||
|
showConsoleLogs &&
|
||||||
|
console.log(
|
||||||
|
`📏 Export text dimensions: width=${textNode.width()}, height=${textNode.height()}, textWidth=${textNode.textWidth}, textHeight=${textNode.textHeight}`,
|
||||||
|
);
|
||||||
|
showConsoleLogs &&
|
||||||
|
console.log(`📏 Element properties: fixedWidth=${processedTextElement.fixedWidth}, offsetX=${processedTextElement.offsetX}`);
|
||||||
|
|
||||||
|
// Convert to image with same resolution as canvas
|
||||||
|
const dataURL = stage.toDataURL({
|
||||||
|
mimeType: 'image/png',
|
||||||
|
quality: 1.0,
|
||||||
|
pixelRatio: 1, // FIXED: Use 1:1 ratio to match canvas resolution
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
stage.destroy();
|
||||||
|
|
||||||
|
showConsoleLogs && console.log(`✅ Text element rendered to image`);
|
||||||
|
return dataURL;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render watermark to image using Konva
|
||||||
|
const renderWatermarkToImage = async (dimensions) => {
|
||||||
|
showConsoleLogs && console.log(`🏷️ Rendering watermark`);
|
||||||
|
|
||||||
|
const stage = new Konva.Stage({
|
||||||
|
container: document.createElement('div'),
|
||||||
|
width: dimensions.width,
|
||||||
|
height: dimensions.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
const layer = new Konva.Layer();
|
||||||
|
stage.add(layer);
|
||||||
|
|
||||||
|
const watermarkText = new Konva.Text({
|
||||||
|
text: 'MEMEAIGEN.COM',
|
||||||
|
x: dimensions.width / 2,
|
||||||
|
y: dimensions.height / 2 + dimensions.height * 0.2,
|
||||||
|
fontSize: WATERMARK_CONFIG.fontSize,
|
||||||
|
fontFamily: WATERMARK_CONFIG.fontFamily,
|
||||||
|
fill: WATERMARK_CONFIG.fill,
|
||||||
|
stroke: WATERMARK_CONFIG.stroke,
|
||||||
|
strokeWidth: WATERMARK_CONFIG.strokeWidth,
|
||||||
|
opacity: WATERMARK_CONFIG.opacity,
|
||||||
|
align: 'center',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
offsetX: 90, // Approximate half-width to center the text
|
||||||
|
offsetY: 5, // Approximate half-height to center the text
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.add(watermarkText);
|
||||||
|
layer.draw();
|
||||||
|
|
||||||
|
const dataURL = stage.toDataURL({
|
||||||
|
mimeType: 'image/png',
|
||||||
|
quality: 1.0,
|
||||||
|
pixelRatio: 1, // FIXED: Match canvas resolution
|
||||||
|
});
|
||||||
|
|
||||||
|
stage.destroy();
|
||||||
|
showConsoleLogs && console.log(`✅ Watermark rendered to image`);
|
||||||
|
return dataURL;
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateFFmpegCommand = useCallback(
|
const generateFFmpegCommand = useCallback(
|
||||||
(is_string = true, useLocalFiles = false) => {
|
(is_string = true, useLocalFiles = false, textImages = {}, watermarkFileName = null) => {
|
||||||
showConsoleLogs && console.log('🎬 STARTING FFmpeg generation');
|
showConsoleLogs && console.log('🎬 STARTING FFmpeg generation');
|
||||||
showConsoleLogs && console.log(`📐 Canvas size: ${dimensions.width}x${dimensions.height}, Duration: ${totalDuration}s`);
|
showConsoleLogs && console.log(`📐 Canvas size: ${dimensions.width}x${dimensions.height}, Duration: ${totalDuration}s`);
|
||||||
|
|
||||||
@@ -163,6 +304,20 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
inputIndex++;
|
inputIndex++;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add text image inputs
|
||||||
|
texts.forEach((text, i) => {
|
||||||
|
inputArgs.push('-loop', '1', '-t', totalDuration.toString(), '-i');
|
||||||
|
inputArgs.push(useLocalFiles ? `text_${i}.png` : textImages[text.id]?.fileName || `text_${i}.png`);
|
||||||
|
inputIndex++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add watermark input if exists
|
||||||
|
if (watermarkFileName) {
|
||||||
|
inputArgs.push('-loop', '1', '-t', totalDuration.toString(), '-i');
|
||||||
|
inputArgs.push(watermarkFileName);
|
||||||
|
inputIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
let filters = [];
|
let filters = [];
|
||||||
filters.push(`color=black:size=${dimensions.width}x${dimensions.height}:duration=${totalDuration}[base]`);
|
filters.push(`color=black:size=${dimensions.width}x${dimensions.height}:duration=${totalDuration}[base]`);
|
||||||
|
|
||||||
@@ -181,9 +336,10 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
allVisualElements.map((el) => `${el.elementType}${el.originalIndex}(L${el.layer || 0})`).join(' → '),
|
allVisualElements.map((el) => `${el.elementType}${el.originalIndex}(L${el.layer || 0})`).join(' → '),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track input indices for videos and images
|
// Track input indices for videos, images, and text images
|
||||||
let videoInputIndex = 0;
|
let videoInputIndex = 0;
|
||||||
let imageInputIndex = videos.length; // Images start after videos
|
let imageInputIndex = videos.length; // Images start after videos
|
||||||
|
let textInputIndex = videos.length + images.length; // Text images start after regular images
|
||||||
|
|
||||||
// Process elements in layer order
|
// Process elements in layer order
|
||||||
allVisualElements.forEach((element, processingIndex) => {
|
allVisualElements.forEach((element, processingIndex) => {
|
||||||
@@ -261,41 +417,24 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
const i = element.originalIndex;
|
const i = element.originalIndex;
|
||||||
|
|
||||||
showConsoleLogs &&
|
showConsoleLogs &&
|
||||||
console.log(`📝 Text ${i} (Layer ${t.layer || 0}) - Position: (${t.x}, ${t.y}) Text: "${t.text.substring(0, 30)}..."`);
|
console.log(`📝 Text ${i} (Layer ${t.layer || 0}) - Text: "${t.text.substring(0, 30)}..." - Using Konva-rendered image`);
|
||||||
|
|
||||||
// Better text escaping for FFmpeg
|
// Use overlay filter for Konva-rendered text image
|
||||||
const escapedText = escapeTextForDrawtext(t.text);
|
filters.push(
|
||||||
|
`[${videoLayer}][${textInputIndex}:v]overlay=0:0:enable='between(t,${t.startTime},${t.startTime + t.duration})'[t${i}_out]`,
|
||||||
|
);
|
||||||
|
|
||||||
// Get the appropriate font file path
|
videoLayer = `t${i}_out`;
|
||||||
const fontFilePath = getFontFilePath(t.fontFamily, t.fontWeight, t.fontStyle);
|
textInputIndex++;
|
||||||
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
|
|
||||||
// FIXED: Wrap enable parameter without quotes to avoid truncation
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIXED: Don't wrap enable parameter in quotes - this was causing the truncation
|
|
||||||
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}`;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add watermark overlay if exists
|
||||||
|
if (watermarkFileName) {
|
||||||
|
filters.push(`[${videoLayer}][${textInputIndex}:v]overlay=0:0[watermark_out]`);
|
||||||
|
videoLayer = 'watermark_out';
|
||||||
|
}
|
||||||
|
|
||||||
showConsoleLogs && console.log('🎵 PROCESSING AUDIO FOR', videos.length, 'VIDEOS');
|
showConsoleLogs && console.log('🎵 PROCESSING AUDIO FOR', videos.length, 'VIDEOS');
|
||||||
|
|
||||||
let audioOutputs = [];
|
let audioOutputs = [];
|
||||||
@@ -358,6 +497,14 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
inputStrings.push(`-loop 1 -t ${img.duration} -i ${escapeShellArg(useLocalFiles ? `input_image_${i}.jpg` : img.source)}`);
|
inputStrings.push(`-loop 1 -t ${img.duration} -i ${escapeShellArg(useLocalFiles ? `input_image_${i}.jpg` : img.source)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
texts.forEach((text, i) => {
|
||||||
|
inputStrings.push(`-loop 1 -t ${totalDuration} -i ${escapeShellArg(useLocalFiles ? `text_${i}.png` : text.id)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (watermarkFileName) {
|
||||||
|
inputStrings.push(`-loop 1 -t ${totalDuration} -i ${escapeShellArg(watermarkFileName)}`);
|
||||||
|
}
|
||||||
|
|
||||||
const inputs = inputStrings.join(' ');
|
const inputs = inputStrings.join(' ');
|
||||||
const audioMap = audioArgs.length > 0 ? ` ${audioArgs.map((arg) => escapeShellArg(arg)).join(' ')}` : '';
|
const audioMap = audioArgs.length > 0 ? ` ${audioArgs.map((arg) => escapeShellArg(arg)).join(' ')}` : '';
|
||||||
const command = `ffmpeg -y -c:v libvpx-vp9 ${inputs} -filter_complex ${escapeShellArg(filterComplex)} -map ${escapeShellArg(`[${videoLayer}]`)}${audioMap} -c:a aac -r 30 -t ${totalDuration} -vcodec libx264 output.mp4`;
|
const command = `ffmpeg -y -c:v libvpx-vp9 ${inputs} -filter_complex ${escapeShellArg(filterComplex)} -map ${escapeShellArg(`[${videoLayer}]`)}${audioMap} -c:a aac -r 30 -t ${totalDuration} -vcodec libx264 output.mp4`;
|
||||||
@@ -380,7 +527,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ffmpegCommand = useMemo(() => {
|
const ffmpegCommand = useMemo(() => {
|
||||||
return generateFFmpegCommand(true, false);
|
return generateFFmpegCommand(true, false, {}, null);
|
||||||
}, [generateFFmpegCommand]);
|
}, [generateFFmpegCommand]);
|
||||||
|
|
||||||
const copyFFmpegCommand = useCallback(() => {
|
const copyFFmpegCommand = useCallback(() => {
|
||||||
@@ -407,7 +554,6 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
showConsoleLogs && console.log('FFmpeg Log:', message);
|
showConsoleLogs && console.log('FFmpeg Log:', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
//const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.10/dist/esm';
|
|
||||||
const baseURL = window.location.origin + '/ffmpeg_packages/core/dist/esm';
|
const baseURL = window.location.origin + '/ffmpeg_packages/core/dist/esm';
|
||||||
const coreURL = `${baseURL}/ffmpeg-core.js`;
|
const coreURL = `${baseURL}/ffmpeg-core.js`;
|
||||||
const wasmURL = `${baseURL}/ffmpeg-core.wasm`;
|
const wasmURL = `${baseURL}/ffmpeg-core.wasm`;
|
||||||
@@ -429,45 +575,40 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
setExportProgress(10);
|
setExportProgress(10);
|
||||||
|
|
||||||
setExportStatus('Loading fonts...');
|
setExportStatus('Loading fonts...');
|
||||||
|
await loadTimelineFonts(timelineElements);
|
||||||
|
showConsoleLogs && console.log('✅ All fonts loaded and ready');
|
||||||
|
setExportProgress(15);
|
||||||
|
|
||||||
// Collect all fonts that need to be loaded with their correct paths
|
setExportStatus('Rendering text elements...');
|
||||||
const fontsToLoad = new Map(); // Map from filename to full path
|
|
||||||
|
|
||||||
// Add Arial font (fallback)
|
// Render text elements to images
|
||||||
fontsToLoad.set('arial.ttf', 'https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf');
|
const texts = timelineElements.filter((el) => el.type === 'text');
|
||||||
|
const textImages = {};
|
||||||
|
|
||||||
// Add fonts used by text elements
|
for (let i = 0; i < texts.length; i++) {
|
||||||
timelineElements
|
const textElement = texts[i];
|
||||||
.filter((el) => el.type === 'text')
|
const dataURL = await renderTextElementToImage(textElement, dimensions);
|
||||||
.forEach((text) => {
|
const imageData = await fetchFile(dataURL);
|
||||||
const fontFilePath = getFontFilePath(text.fontFamily, text.fontWeight, text.fontStyle);
|
|
||||||
const fontFileName = fontFilePath.split('/').pop();
|
|
||||||
|
|
||||||
// Only add if not already in map and not arial.ttf
|
const fileName = `text_${i}.png`;
|
||||||
if (fontFileName !== 'arial.ttf' && !fontsToLoad.has(fontFileName)) {
|
await ffmpeg.writeFile(fileName, imageData);
|
||||||
fontsToLoad.set(fontFileName, fontFilePath);
|
textImages[textElement.id] = { fileName, index: i };
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
showConsoleLogs && console.log('Fonts to load:', Array.from(fontsToLoad.entries()));
|
setExportProgress(15 + Math.round((i / texts.length) * 15));
|
||||||
|
|
||||||
// Load each unique font
|
|
||||||
let fontProgress = 0;
|
|
||||||
for (const [fontFileName, fontPath] of fontsToLoad) {
|
|
||||||
try {
|
|
||||||
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.error(`❌ Failed to load font ${fontFileName} from ${fontPath}:`, error);
|
|
||||||
// If font loading fails, we'll use arial.ttf as fallback
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showConsoleLogs && console.log('All fonts loaded!');
|
// Render watermark if needed (you'll need to pass watermarked prop)
|
||||||
setExportProgress(20);
|
let watermarkFileName = null;
|
||||||
|
// Uncomment if you have watermarked prop available:
|
||||||
|
// if (watermarked) {
|
||||||
|
// const watermarkDataURL = await renderWatermarkToImage(dimensions);
|
||||||
|
// const watermarkImageData = await fetchFile(watermarkDataURL);
|
||||||
|
// watermarkFileName = 'watermark.png';
|
||||||
|
// await ffmpeg.writeFile(watermarkFileName, watermarkImageData);
|
||||||
|
// }
|
||||||
|
|
||||||
|
setExportProgress(30);
|
||||||
|
showConsoleLogs && console.log('✅ All text elements rendered to images');
|
||||||
|
|
||||||
setExportStatus('Downloading media...');
|
setExportStatus('Downloading media...');
|
||||||
const videos = timelineElements.filter((el) => el.type === 'video');
|
const videos = timelineElements.filter((el) => el.type === 'video');
|
||||||
@@ -489,7 +630,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
throw new Error(`Failed to download video ${i}: ${error.message}`);
|
throw new Error(`Failed to download video ${i}: ${error.message}`);
|
||||||
}
|
}
|
||||||
mediaProgress++;
|
mediaProgress++;
|
||||||
setExportProgress(20 + Math.round((mediaProgress / totalMedia) * 40));
|
setExportProgress(30 + Math.round((mediaProgress / totalMedia) * 40));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download images
|
// Download images
|
||||||
@@ -503,7 +644,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
throw new Error(`Failed to download image ${i}: ${error.message}`);
|
throw new Error(`Failed to download image ${i}: ${error.message}`);
|
||||||
}
|
}
|
||||||
mediaProgress++;
|
mediaProgress++;
|
||||||
setExportProgress(20 + Math.round((mediaProgress / totalMedia) * 40));
|
setExportProgress(30 + Math.round((mediaProgress / totalMedia) * 40));
|
||||||
}
|
}
|
||||||
|
|
||||||
showConsoleLogs && console.log('All media downloaded successfully!');
|
showConsoleLogs && console.log('All media downloaded successfully!');
|
||||||
@@ -517,11 +658,10 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setExportStatus('Processing video...');
|
setExportStatus('Processing video...');
|
||||||
let args = generateFFmpegCommand(false, true);
|
let args = generateFFmpegCommand(false, true, textImages, watermarkFileName);
|
||||||
|
|
||||||
showConsoleLogs && console.log('Generated FFmpeg arguments:', args);
|
showConsoleLogs && console.log('Generated FFmpeg arguments:', args);
|
||||||
|
showConsoleLogs && console.log(generateFFmpegCommand(true, true, textImages, watermarkFileName));
|
||||||
showConsoleLogs && console.log(generateFFmpegCommand(true, true));
|
|
||||||
|
|
||||||
setExportProgress(70);
|
setExportProgress(70);
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { useMitt } from '@/plugins/MittContext';
|
import { useMitt } from '@/plugins/MittContext';
|
||||||
import useVideoEditorStore from '@/stores/VideoEditorStore';
|
import useVideoEditorStore from '@/stores/VideoEditorStore';
|
||||||
import { Type } from 'lucide-react';
|
import { Type } from 'lucide-react';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Group, Image, Layer, Line, Stage, Text, Transformer } from 'react-konva';
|
import { Group, Image, Layer, Line, Stage, Text, Transformer } from 'react-konva';
|
||||||
import { Html } from 'react-konva-utils';
|
import { Html } from 'react-konva-utils';
|
||||||
|
|
||||||
// Import our custom hooks and utilities
|
// Import our custom hooks and utilities
|
||||||
import { useElementSelection } from './video-preview/video-preview-element-selection';
|
import { useElementSelection } from './video-preview/video-preview-element-selection';
|
||||||
import { useElementTransform } from './video-preview/video-preview-element-transform';
|
import { useElementTransform } from './video-preview/video-preview-element-transform';
|
||||||
import { getImageSource, getTextFontStyle } from './video-preview/video-preview-utils';
|
import { getImageSource } from './video-preview/video-preview-utils';
|
||||||
|
|
||||||
|
// Import centralized font management
|
||||||
|
import { getFontStyle, loadTimelineFonts, WATERMARK_CONFIG } from '@/modules/editor/fonts';
|
||||||
|
|
||||||
const VideoPreview = ({
|
const VideoPreview = ({
|
||||||
watermarked,
|
watermarked,
|
||||||
@@ -56,6 +59,51 @@ const VideoPreview = ({
|
|||||||
const stageRef = useRef(null);
|
const stageRef = useRef(null);
|
||||||
const elementRefs = useRef({});
|
const elementRefs = useRef({});
|
||||||
|
|
||||||
|
// Font loading state
|
||||||
|
const [fontsLoaded, setFontsLoaded] = useState(false);
|
||||||
|
const [fontLoadingAttempted, setFontLoadingAttempted] = useState(false);
|
||||||
|
|
||||||
|
// Load fonts when timeline elements change
|
||||||
|
useEffect(() => {
|
||||||
|
const loadFonts = async () => {
|
||||||
|
if (timelineElements.length > 0 && !fontLoadingAttempted) {
|
||||||
|
setFontLoadingAttempted(true);
|
||||||
|
try {
|
||||||
|
await loadTimelineFonts(timelineElements);
|
||||||
|
setFontsLoaded(true);
|
||||||
|
console.log('✅ Fonts loaded in preview');
|
||||||
|
|
||||||
|
// Force redraw after fonts load to recalculate text dimensions
|
||||||
|
setTimeout(() => {
|
||||||
|
if (layerRef.current) {
|
||||||
|
layerRef.current.batchDraw();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Font loading failed:', error);
|
||||||
|
setFontsLoaded(true); // Continue anyway with fallback fonts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadFonts();
|
||||||
|
}, [timelineElements, fontLoadingAttempted]);
|
||||||
|
|
||||||
|
// Force text remeasurement when fonts load
|
||||||
|
useEffect(() => {
|
||||||
|
if (fontsLoaded && layerRef.current) {
|
||||||
|
// Find all text nodes and force them to recalculate
|
||||||
|
const textNodes = layerRef.current.find('Text');
|
||||||
|
textNodes.forEach((textNode) => {
|
||||||
|
// Force Konva to recalculate text dimensions
|
||||||
|
textNode._setTextData();
|
||||||
|
textNode.cache();
|
||||||
|
textNode.clearCache();
|
||||||
|
});
|
||||||
|
layerRef.current.batchDraw();
|
||||||
|
}
|
||||||
|
}, [fontsLoaded]);
|
||||||
|
|
||||||
// Use our custom hooks
|
// Use our custom hooks
|
||||||
const {
|
const {
|
||||||
selectedElementId,
|
selectedElementId,
|
||||||
@@ -140,19 +188,44 @@ const VideoPreview = ({
|
|||||||
);
|
);
|
||||||
} else if (element.type === 'text') {
|
} else if (element.type === 'text') {
|
||||||
return (
|
return (
|
||||||
<Group key={element.id}>
|
<Group key={`${element.id}-${fontsLoaded}`}>
|
||||||
<Text
|
<Text
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
if (node) {
|
if (node) {
|
||||||
elementRefs.current[element.id] = node;
|
elementRefs.current[element.id] = node;
|
||||||
|
|
||||||
|
// Force text measurement after font loading
|
||||||
|
if (fontsLoaded) {
|
||||||
|
setTimeout(() => {
|
||||||
|
node._setTextData();
|
||||||
|
|
||||||
|
// Debug log preview text properties
|
||||||
|
console.log(`🔍 Preview Text ${element.id} Properties:`);
|
||||||
|
console.log(' text:', element.text);
|
||||||
|
console.log(' x:', element.x);
|
||||||
|
console.log(' y:', element.y);
|
||||||
|
console.log(' fontSize:', element.fontSize);
|
||||||
|
console.log(' fontFamily:', element.fontFamily);
|
||||||
|
console.log(' width (fixedWidth):', element.fixedWidth);
|
||||||
|
console.log(' offsetX:', element.offsetX);
|
||||||
|
console.log(' node.width():', node.width());
|
||||||
|
console.log(' node.height():', node.height());
|
||||||
|
console.log(' node.textWidth:', node.textWidth);
|
||||||
|
console.log(' node.textHeight:', node.textHeight);
|
||||||
|
|
||||||
|
if (layerRef.current) {
|
||||||
|
layerRef.current.batchDraw();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
text={element.text}
|
text={element.text}
|
||||||
x={element.x}
|
x={element.x}
|
||||||
y={element.y}
|
y={element.y}
|
||||||
fontSize={element.fontSize}
|
fontSize={element.fontSize}
|
||||||
fontStyle={getTextFontStyle(element)}
|
fontStyle={getFontStyle(element)} // Use centralized function
|
||||||
fontFamily={element.fontFamily || 'Arial'}
|
fontFamily={element.fontFamily || 'Montserrat'}
|
||||||
fill={element.fill || '#ffffff'}
|
fill={element.fill || '#ffffff'}
|
||||||
stroke={element.strokeWidth > 0 ? element.stroke || '#000000' : undefined}
|
stroke={element.strokeWidth > 0 ? element.stroke || '#000000' : undefined}
|
||||||
strokeWidth={element.strokeWidth * 3 || 0}
|
strokeWidth={element.strokeWidth * 3 || 0}
|
||||||
@@ -246,15 +319,16 @@ const VideoPreview = ({
|
|||||||
{/* Watermark - only show when watermarked is true */}
|
{/* Watermark - only show when watermarked is true */}
|
||||||
{watermarked && (
|
{watermarked && (
|
||||||
<Text
|
<Text
|
||||||
|
key={`watermark-${fontsLoaded}`}
|
||||||
text="MEMEAIGEN.COM"
|
text="MEMEAIGEN.COM"
|
||||||
x={dimensions.width / 2}
|
x={dimensions.width / 2}
|
||||||
y={dimensions.height / 2 + dimensions.height * 0.2}
|
y={dimensions.height / 2 + dimensions.height * 0.2}
|
||||||
fontSize={20}
|
fontSize={WATERMARK_CONFIG.fontSize}
|
||||||
fontFamily="Bungee"
|
fontFamily={WATERMARK_CONFIG.fontFamily}
|
||||||
fill="white"
|
fill={WATERMARK_CONFIG.fill}
|
||||||
stroke="black"
|
stroke={WATERMARK_CONFIG.stroke}
|
||||||
strokeWidth={2}
|
strokeWidth={WATERMARK_CONFIG.strokeWidth}
|
||||||
opacity={0.5}
|
opacity={WATERMARK_CONFIG.opacity}
|
||||||
align="center"
|
align="center"
|
||||||
verticalAlign="middle"
|
verticalAlign="middle"
|
||||||
offsetX={90} // Approximate half-width to center the text
|
offsetX={90} // Approximate half-width to center the text
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// video-preview-utils.js
|
// video-preview-utils.js
|
||||||
|
|
||||||
|
// Import centralized font management
|
||||||
|
import { getFontStyle } from '@/modules/editor/fonts';
|
||||||
|
|
||||||
// Snap settings
|
// Snap settings
|
||||||
export const POSITION_SNAP_THRESHOLD = 10; // Pixels within which to snap to center
|
export const POSITION_SNAP_THRESHOLD = 10; // Pixels within which to snap to center
|
||||||
|
|
||||||
@@ -16,21 +19,8 @@ export const getImageSource = (element, videoStates, isPlaying) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to get font style for text elements
|
// Re-export the centralized font style function for backward compatibility
|
||||||
export const getTextFontStyle = (element) => {
|
export { getFontStyle as getTextFontStyle };
|
||||||
const isBold = element.fontWeight === 'bold' || element.fontWeight === 700;
|
|
||||||
const isItalic = element.fontStyle === 'italic';
|
|
||||||
|
|
||||||
if (isBold && isItalic) {
|
|
||||||
return 'bold italic';
|
|
||||||
} else if (isBold) {
|
|
||||||
return 'bold';
|
|
||||||
} else if (isItalic) {
|
|
||||||
return 'italic';
|
|
||||||
} else {
|
|
||||||
return 'normal';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if element uses center-offset positioning
|
// Check if element uses center-offset positioning
|
||||||
export const usesCenterPositioning = (elementType) => {
|
export const usesCenterPositioning = (elementType) => {
|
||||||
|
|||||||
@@ -8,19 +8,8 @@ import useVideoEditorStore from '@/stores/VideoEditorStore';
|
|||||||
import { Bold, Italic, Minus, Plus, Type } from 'lucide-react';
|
import { Bold, Italic, Minus, Plus, Type } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
// Font configuration - extensible for adding more fonts
|
// Import centralized font management
|
||||||
const AVAILABLE_FONTS = [
|
import { DEFAULT_TEXT_CONFIG, getAvailableFonts, loadFonts } from '@/modules/editor/fonts';
|
||||||
{
|
|
||||||
name: 'Montserrat',
|
|
||||||
value: 'Montserrat',
|
|
||||||
fontFiles: {
|
|
||||||
normal: '/fonts/Montserrat/static/Montserrat-Regular.ttf',
|
|
||||||
bold: '/fonts/Montserrat/static/Montserrat-Bold.ttf',
|
|
||||||
italic: '/fonts/Montserrat/static/Montserrat-Italic.ttf',
|
|
||||||
boldItalic: '/fonts/Montserrat/static/Montserrat-BoldItalic.ttf',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function TextSidebar({ isOpen, onClose }) {
|
export default function TextSidebar({ isOpen, onClose }) {
|
||||||
const { selectedTextElement } = useVideoEditorStore();
|
const { selectedTextElement } = useVideoEditorStore();
|
||||||
@@ -44,17 +33,41 @@ export default function TextSidebar({ isOpen, onClose }) {
|
|||||||
const MAX_STROKE_WIDTH = 3;
|
const MAX_STROKE_WIDTH = 3;
|
||||||
const STROKE_WIDTH_STEP = 1;
|
const STROKE_WIDTH_STEP = 1;
|
||||||
|
|
||||||
|
// Get available fonts from centralized source
|
||||||
|
const availableFonts = getAvailableFonts();
|
||||||
|
|
||||||
|
// Load fonts for preview
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPreviewFonts = async () => {
|
||||||
|
if (fontFamily) {
|
||||||
|
try {
|
||||||
|
await loadFonts([
|
||||||
|
{
|
||||||
|
fontFamily,
|
||||||
|
fontWeight: isBold ? 700 : 400,
|
||||||
|
fontStyle: isItalic ? 'italic' : 'normal',
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load preview font:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadPreviewFonts();
|
||||||
|
}, [fontFamily, isBold, isItalic]);
|
||||||
|
|
||||||
// Update state when selected element changes - THIS KEEPS SIDEBAR IN SYNC WITH TRANSFORMER
|
// Update state when selected element changes - THIS KEEPS SIDEBAR IN SYNC WITH TRANSFORMER
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTextElement) {
|
if (selectedTextElement) {
|
||||||
setTextValue(selectedTextElement.text || '');
|
setTextValue(selectedTextElement.text || '');
|
||||||
setFontSize(selectedTextElement.fontSize || 24);
|
setFontSize(selectedTextElement.fontSize || DEFAULT_TEXT_CONFIG.fontSize);
|
||||||
setIsBold(selectedTextElement.fontWeight === 'bold' || selectedTextElement.fontWeight === 700 || true);
|
setIsBold(selectedTextElement.fontWeight === 'bold' || selectedTextElement.fontWeight === 700 || true);
|
||||||
setIsItalic(selectedTextElement.fontStyle === 'italic' || false);
|
setIsItalic(selectedTextElement.fontStyle === 'italic' || false);
|
||||||
setFontFamily(selectedTextElement.fontFamily || 'Montserrat');
|
setFontFamily(selectedTextElement.fontFamily || DEFAULT_TEXT_CONFIG.fontFamily);
|
||||||
setFillColor(selectedTextElement.fill || '#ffffff');
|
setFillColor(selectedTextElement.fill || DEFAULT_TEXT_CONFIG.fill);
|
||||||
setStrokeColor(selectedTextElement.stroke || '#000000');
|
setStrokeColor(selectedTextElement.stroke || DEFAULT_TEXT_CONFIG.stroke);
|
||||||
setStrokeWidth(selectedTextElement.strokeWidth || 2);
|
setStrokeWidth(selectedTextElement.strokeWidth || DEFAULT_TEXT_CONFIG.strokeWidth);
|
||||||
}
|
}
|
||||||
}, [selectedTextElement]);
|
}, [selectedTextElement]);
|
||||||
|
|
||||||
@@ -231,11 +244,11 @@ export default function TextSidebar({ isOpen, onClose }) {
|
|||||||
<SelectValue placeholder="Select font" />
|
<SelectValue placeholder="Select font" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{AVAILABLE_FONTS.map((font) => (
|
{availableFonts.map((font) => (
|
||||||
<SelectItem key={font.value} value={font.value}>
|
<SelectItem key={font.value} value={font.value}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontFamily: font.name,
|
fontFamily: font.value,
|
||||||
fontWeight: isBold ? 'bold' : 'normal',
|
fontWeight: isBold ? 'bold' : 'normal',
|
||||||
fontStyle: isItalic ? 'italic' : 'normal',
|
fontStyle: isItalic ? 'italic' : 'normal',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
"y": 200,
|
"y": 200,
|
||||||
"fontSize": 40,
|
"fontSize": 40,
|
||||||
"fontWeight": "bold",
|
"fontWeight": "bold",
|
||||||
"fontFamily": "Arial",
|
"fontFamily": "Montserrat",
|
||||||
"fontStyle": "normal",
|
"fontStyle": "normal",
|
||||||
"fill": "#ffffff",
|
"fill": "#ffffff",
|
||||||
"stroke": "#000000",
|
"stroke": "#000000",
|
||||||
|
|||||||
@@ -77,6 +77,21 @@ export const generateTimelineFromTemplate = (dimensions, template, mediaStoreDat
|
|||||||
case 'caption':
|
case 'caption':
|
||||||
if (currentCaption) {
|
if (currentCaption) {
|
||||||
processedElement.text = currentCaption;
|
processedElement.text = currentCaption;
|
||||||
|
|
||||||
|
// Calculate text width properties for better rendering consistency
|
||||||
|
const textWidth = Math.min(dimensions.width * 0.8, 600); // Max 80% of canvas width or 600px
|
||||||
|
processedElement.fixedWidth = textWidth;
|
||||||
|
processedElement.offsetX = textWidth / 2; // Center alignment offset
|
||||||
|
|
||||||
|
// Ensure text is positioned properly (center horizontally)
|
||||||
|
if (!processedElement.x || processedElement.x === 0) {
|
||||||
|
processedElement.x = dimensions.width / 2; // Center horizontally
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure text has proper vertical positioning
|
||||||
|
if (!processedElement.y || processedElement.y === 0) {
|
||||||
|
processedElement.y = dimensions.height * 0.1; // 10% from top
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return null; // Skip if no caption
|
return null; // Skip if no caption
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const Ziggy = {"url":"https:\/\/memeaigen.com","port":null,"defaults":{},"routes":{"horizon.stats.index":{"uri":"horizon\/api\/stats","methods":["GET","HEAD"]},"horizon.workload.index":{"uri":"horizon\/api\/workload","methods":["GET","HEAD"]},"horizon.masters.index":{"uri":"horizon\/api\/masters","methods":["GET","HEAD"]},"horizon.monitoring.index":{"uri":"horizon\/api\/monitoring","methods":["GET","HEAD"]},"horizon.monitoring.store":{"uri":"horizon\/api\/monitoring","methods":["POST"]},"horizon.monitoring-tag.paginate":{"uri":"horizon\/api\/monitoring\/{tag}","methods":["GET","HEAD"],"parameters":["tag"]},"horizon.monitoring-tag.destroy":{"uri":"horizon\/api\/monitoring\/{tag}","methods":["DELETE"],"wheres":{"tag":".*"},"parameters":["tag"]},"horizon.jobs-metrics.index":{"uri":"horizon\/api\/metrics\/jobs","methods":["GET","HEAD"]},"horizon.jobs-metrics.show":{"uri":"horizon\/api\/metrics\/jobs\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.queues-metrics.index":{"uri":"horizon\/api\/metrics\/queues","methods":["GET","HEAD"]},"horizon.queues-metrics.show":{"uri":"horizon\/api\/metrics\/queues\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.jobs-batches.index":{"uri":"horizon\/api\/batches","methods":["GET","HEAD"]},"horizon.jobs-batches.show":{"uri":"horizon\/api\/batches\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.jobs-batches.retry":{"uri":"horizon\/api\/batches\/retry\/{id}","methods":["POST"],"parameters":["id"]},"horizon.pending-jobs.index":{"uri":"horizon\/api\/jobs\/pending","methods":["GET","HEAD"]},"horizon.completed-jobs.index":{"uri":"horizon\/api\/jobs\/completed","methods":["GET","HEAD"]},"horizon.silenced-jobs.index":{"uri":"horizon\/api\/jobs\/silenced","methods":["GET","HEAD"]},"horizon.failed-jobs.index":{"uri":"horizon\/api\/jobs\/failed","methods":["GET","HEAD"]},"horizon.failed-jobs.show":{"uri":"horizon\/api\/jobs\/failed\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.retry-jobs.show":{"uri":"horizon\/api\/jobs\/retry\/{id}","methods":["POST"],"parameters":["id"]},"horizon.jobs.show":{"uri":"horizon\/api\/jobs\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.index":{"uri":"horizon\/{view?}","methods":["GET","HEAD"],"wheres":{"view":"(.*)"},"parameters":["view"]},"sanctum.csrf-cookie":{"uri":"sanctum\/csrf-cookie","methods":["GET","HEAD"]},"api.app.init":{"uri":"api\/app\/init","methods":["POST"]},"api.app.memes":{"uri":"api\/app\/memes","methods":["POST"]},"api.app.background":{"uri":"api\/app\/background","methods":["POST"]},"dashboard":{"uri":"dashboard","methods":["GET","HEAD"]},"admin.dashboard":{"uri":"admin","methods":["GET","HEAD"]},"admin.background-generation":{"uri":"admin\/background-generation","methods":["GET","HEAD"]},"admin.background-generation.generate":{"uri":"admin\/background-generation\/generate","methods":["POST"]},"admin.background-generation.save":{"uri":"admin\/background-generation\/save","methods":["POST"]},"admin.background-generation.delete":{"uri":"admin\/background-generation\/delete\/{id}","methods":["POST"],"parameters":["id"]},"profile.edit":{"uri":"settings\/profile","methods":["GET","HEAD"]},"profile.update":{"uri":"settings\/profile","methods":["PATCH"]},"profile.destroy":{"uri":"settings\/profile","methods":["DELETE"]},"password.edit":{"uri":"settings\/password","methods":["GET","HEAD"]},"password.update":{"uri":"settings\/password","methods":["PUT"]},"appearance":{"uri":"settings\/appearance","methods":["GET","HEAD"]},"register":{"uri":"register","methods":["GET","HEAD"]},"login":{"uri":"login","methods":["GET","HEAD"]},"password.request":{"uri":"forgot-password","methods":["GET","HEAD"]},"password.email":{"uri":"forgot-password","methods":["POST"]},"password.reset":{"uri":"reset-password\/{token}","methods":["GET","HEAD"],"parameters":["token"]},"password.store":{"uri":"reset-password","methods":["POST"]},"verification.notice":{"uri":"verify-email","methods":["GET","HEAD"]},"verification.verify":{"uri":"verify-email\/{id}\/{hash}","methods":["GET","HEAD"],"parameters":["id","hash"]},"verification.send":{"uri":"email\/verification-notification","methods":["POST"]},"password.confirm":{"uri":"confirm-password","methods":["GET","HEAD"]},"logout":{"uri":"logout","methods":["POST"]},"home":{"uri":"\/","methods":["GET","HEAD"]},"storage.local":{"uri":"storage\/{path}","methods":["GET","HEAD"],"wheres":{"path":".*"},"parameters":["path"]}}};
|
const Ziggy = {"url":"https:\/\/memeaigen.test","port":null,"defaults":{},"routes":{"horizon.stats.index":{"uri":"horizon\/api\/stats","methods":["GET","HEAD"]},"horizon.workload.index":{"uri":"horizon\/api\/workload","methods":["GET","HEAD"]},"horizon.masters.index":{"uri":"horizon\/api\/masters","methods":["GET","HEAD"]},"horizon.monitoring.index":{"uri":"horizon\/api\/monitoring","methods":["GET","HEAD"]},"horizon.monitoring.store":{"uri":"horizon\/api\/monitoring","methods":["POST"]},"horizon.monitoring-tag.paginate":{"uri":"horizon\/api\/monitoring\/{tag}","methods":["GET","HEAD"],"parameters":["tag"]},"horizon.monitoring-tag.destroy":{"uri":"horizon\/api\/monitoring\/{tag}","methods":["DELETE"],"wheres":{"tag":".*"},"parameters":["tag"]},"horizon.jobs-metrics.index":{"uri":"horizon\/api\/metrics\/jobs","methods":["GET","HEAD"]},"horizon.jobs-metrics.show":{"uri":"horizon\/api\/metrics\/jobs\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.queues-metrics.index":{"uri":"horizon\/api\/metrics\/queues","methods":["GET","HEAD"]},"horizon.queues-metrics.show":{"uri":"horizon\/api\/metrics\/queues\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.jobs-batches.index":{"uri":"horizon\/api\/batches","methods":["GET","HEAD"]},"horizon.jobs-batches.show":{"uri":"horizon\/api\/batches\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.jobs-batches.retry":{"uri":"horizon\/api\/batches\/retry\/{id}","methods":["POST"],"parameters":["id"]},"horizon.pending-jobs.index":{"uri":"horizon\/api\/jobs\/pending","methods":["GET","HEAD"]},"horizon.completed-jobs.index":{"uri":"horizon\/api\/jobs\/completed","methods":["GET","HEAD"]},"horizon.silenced-jobs.index":{"uri":"horizon\/api\/jobs\/silenced","methods":["GET","HEAD"]},"horizon.failed-jobs.index":{"uri":"horizon\/api\/jobs\/failed","methods":["GET","HEAD"]},"horizon.failed-jobs.show":{"uri":"horizon\/api\/jobs\/failed\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.retry-jobs.show":{"uri":"horizon\/api\/jobs\/retry\/{id}","methods":["POST"],"parameters":["id"]},"horizon.jobs.show":{"uri":"horizon\/api\/jobs\/{id}","methods":["GET","HEAD"],"parameters":["id"]},"horizon.index":{"uri":"horizon\/{view?}","methods":["GET","HEAD"],"wheres":{"view":"(.*)"},"parameters":["view"]},"sanctum.csrf-cookie":{"uri":"sanctum\/csrf-cookie","methods":["GET","HEAD"]},"api.app.init":{"uri":"api\/app\/init","methods":["POST"]},"api.app.memes":{"uri":"api\/app\/memes","methods":["POST"]},"api.app.background":{"uri":"api\/app\/background","methods":["POST"]},"dashboard":{"uri":"dashboard","methods":["GET","HEAD"]},"admin.dashboard":{"uri":"admin","methods":["GET","HEAD"]},"admin.background-generation":{"uri":"admin\/background-generation","methods":["GET","HEAD"]},"admin.background-generation.generate":{"uri":"admin\/background-generation\/generate","methods":["POST"]},"admin.background-generation.save":{"uri":"admin\/background-generation\/save","methods":["POST"]},"admin.background-generation.delete":{"uri":"admin\/background-generation\/delete\/{id}","methods":["POST"],"parameters":["id"]},"profile.edit":{"uri":"settings\/profile","methods":["GET","HEAD"]},"profile.update":{"uri":"settings\/profile","methods":["PATCH"]},"profile.destroy":{"uri":"settings\/profile","methods":["DELETE"]},"password.edit":{"uri":"settings\/password","methods":["GET","HEAD"]},"password.update":{"uri":"settings\/password","methods":["PUT"]},"appearance":{"uri":"settings\/appearance","methods":["GET","HEAD"]},"register":{"uri":"register","methods":["GET","HEAD"]},"login":{"uri":"login","methods":["GET","HEAD"]},"password.request":{"uri":"forgot-password","methods":["GET","HEAD"]},"password.email":{"uri":"forgot-password","methods":["POST"]},"password.reset":{"uri":"reset-password\/{token}","methods":["GET","HEAD"],"parameters":["token"]},"password.store":{"uri":"reset-password","methods":["POST"]},"verification.notice":{"uri":"verify-email","methods":["GET","HEAD"]},"verification.verify":{"uri":"verify-email\/{id}\/{hash}","methods":["GET","HEAD"],"parameters":["id","hash"]},"verification.send":{"uri":"email\/verification-notification","methods":["POST"]},"password.confirm":{"uri":"confirm-password","methods":["GET","HEAD"]},"logout":{"uri":"logout","methods":["POST"]},"home":{"uri":"\/","methods":["GET","HEAD"]},"storage.local":{"uri":"storage\/{path}","methods":["GET","HEAD"],"wheres":{"path":".*"},"parameters":["path"]}}};
|
||||||
if (typeof window !== 'undefined' && typeof window.Ziggy !== 'undefined') {
|
if (typeof window !== 'undefined' && typeof window.Ziggy !== 'undefined') {
|
||||||
Object.assign(Ziggy.routes, window.Ziggy.routes);
|
Object.assign(Ziggy.routes, window.Ziggy.routes);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user