This commit is contained in:
ct
2025-06-28 12:54:30 +08:00
parent f8dc4c01f2
commit fe1066583a
13 changed files with 781 additions and 175 deletions

View 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.

View File

@@ -4,6 +4,10 @@ import { useMitt } from '@/plugins/MittContext';
import useLocalSettingsStore from '@/stores/localSettingsStore';
import useMediaStore from '@/stores/MediaStore';
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 EditSidebar from './partials/edit-sidebar';
import EditorCanvas from './partials/editor-canvas';

View 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,
};

View File

@@ -57,7 +57,7 @@ const sampleTimelineElements = [
startTime: 0,
layer: 2,
duration: 4,
x: 90,
x: 360, // Center horizontally (720/2)
y: 180,
fontSize: 40,
fontWeight: 'bold',
@@ -67,6 +67,9 @@ const sampleTimelineElements = [
stroke: '#000000',
strokeWidth: 3,
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',
@@ -75,7 +78,7 @@ const sampleTimelineElements = [
startTime: 3,
layer: 3,
duration: 4,
x: 50,
x: 360, // Center horizontally (720/2)
y: 650,
fontSize: 20,
fontWeight: 'bold',
@@ -85,6 +88,9 @@ const sampleTimelineElements = [
stroke: '#ff0000',
strokeWidth: 2,
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',

View File

@@ -1,28 +1,10 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';
import Konva from 'konva';
import { useCallback, useEffect, useMemo, useState } from 'react';
// Font configuration mapping
const FONT_CONFIG = {
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',
},
};
// Import centralized font management
import { getFontStyle, loadTimelineFonts, WATERMARK_CONFIG } from '@/modules/editor/fonts';
const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
const [showConsoleLogs] = useState(true);
@@ -31,25 +13,6 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
const [exportProgress, setExportProgress] = useState(0);
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(() => {
console.log(JSON.stringify(timelineElements));
}, [timelineElements]);
@@ -82,24 +45,202 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
return arg;
};
// Better text escaping function for FFmpeg drawtext filter
const escapeTextForDrawtext = (text) => {
return text
.replace(/\\/g, '\\\\\\\\') // Escape backslashes - needs 4 backslashes for proper escaping
.replace(/'/g, "\\'") // Escape single quotes
.replace(/:/g, '\\:') // Escape colons
.replace(/\[/g, '\\[') // Escape square brackets
.replace(/\]/g, '\\]')
.replace(/,/g, '\\,') // Escape commas
.replace(/;/g, '\\;') // Escape semicolons
.replace(/\|/g, '\\|') // Escape pipes
.replace(/\n/g, ' ') // Replace newlines with spaces
.replace(/\r/g, ' ') // Replace carriage returns with spaces
.replace(/\t/g, ' '); // Replace tabs with spaces
// Ensure text element has proper width properties for consistent rendering
const ensureTextProperties = async (textElement, dimensions) => {
// If fixedWidth and offsetX are already set, use them
if (textElement.fixedWidth !== undefined && textElement.offsetX !== undefined) {
return textElement;
}
// Create a temporary stage to measure text dimensions
const tempStage = new Konva.Stage({
container: document.createElement('div'),
width: dimensions.width,
height: dimensions.height,
});
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(
(is_string = true, useLocalFiles = false) => {
(is_string = true, useLocalFiles = false, textImages = {}, watermarkFileName = null) => {
showConsoleLogs && console.log('🎬 STARTING FFmpeg generation');
showConsoleLogs && console.log(`📐 Canvas size: ${dimensions.width}x${dimensions.height}, Duration: ${totalDuration}s`);
@@ -163,6 +304,20 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
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 = [];
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(' → '),
);
// Track input indices for videos and images
// Track input indices for videos, images, and text images
let videoInputIndex = 0;
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
allVisualElements.forEach((element, processingIndex) => {
@@ -261,41 +417,24 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
const i = element.originalIndex;
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
const escapedText = escapeTextForDrawtext(t.text);
// Use overlay filter for Konva-rendered text image
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
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
// 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}`;
videoLayer = `t${i}_out`;
textInputIndex++;
}
});
// 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');
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)}`);
});
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 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`;
@@ -380,7 +527,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
);
const ffmpegCommand = useMemo(() => {
return generateFFmpegCommand(true, false);
return generateFFmpegCommand(true, false, {}, null);
}, [generateFFmpegCommand]);
const copyFFmpegCommand = useCallback(() => {
@@ -407,7 +554,6 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
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 coreURL = `${baseURL}/ffmpeg-core.js`;
const wasmURL = `${baseURL}/ffmpeg-core.wasm`;
@@ -429,45 +575,40 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
setExportProgress(10);
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
const fontsToLoad = new Map(); // Map from filename to full path
setExportStatus('Rendering text elements...');
// Add Arial font (fallback)
fontsToLoad.set('arial.ttf', 'https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf');
// Render text elements to images
const texts = timelineElements.filter((el) => el.type === 'text');
const textImages = {};
// Add fonts used by text elements
timelineElements
.filter((el) => el.type === 'text')
.forEach((text) => {
const fontFilePath = getFontFilePath(text.fontFamily, text.fontWeight, text.fontStyle);
const fontFileName = fontFilePath.split('/').pop();
for (let i = 0; i < texts.length; i++) {
const textElement = texts[i];
const dataURL = await renderTextElementToImage(textElement, dimensions);
const imageData = await fetchFile(dataURL);
// Only add if not already in map and not arial.ttf
if (fontFileName !== 'arial.ttf' && !fontsToLoad.has(fontFileName)) {
fontsToLoad.set(fontFileName, fontFilePath);
}
});
const fileName = `text_${i}.png`;
await ffmpeg.writeFile(fileName, imageData);
textImages[textElement.id] = { fileName, index: i };
showConsoleLogs && console.log('Fonts to load:', Array.from(fontsToLoad.entries()));
// 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
}
setExportProgress(15 + Math.round((i / texts.length) * 15));
}
showConsoleLogs && console.log('All fonts loaded!');
setExportProgress(20);
// Render watermark if needed (you'll need to pass watermarked prop)
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...');
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}`);
}
mediaProgress++;
setExportProgress(20 + Math.round((mediaProgress / totalMedia) * 40));
setExportProgress(30 + Math.round((mediaProgress / totalMedia) * 40));
}
// Download images
@@ -503,7 +644,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
throw new Error(`Failed to download image ${i}: ${error.message}`);
}
mediaProgress++;
setExportProgress(20 + Math.round((mediaProgress / totalMedia) * 40));
setExportProgress(30 + Math.round((mediaProgress / totalMedia) * 40));
}
showConsoleLogs && console.log('All media downloaded successfully!');
@@ -517,11 +658,10 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
}
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(generateFFmpegCommand(true, true));
showConsoleLogs && console.log(generateFFmpegCommand(true, true, textImages, watermarkFileName));
setExportProgress(70);

View File

@@ -2,14 +2,17 @@ import { Button } from '@/components/ui/button';
import { useMitt } from '@/plugins/MittContext';
import useVideoEditorStore from '@/stores/VideoEditorStore';
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 { Html } from 'react-konva-utils';
// Import our custom hooks and utilities
import { useElementSelection } from './video-preview/video-preview-element-selection';
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 = ({
watermarked,
@@ -56,6 +59,51 @@ const VideoPreview = ({
const stageRef = useRef(null);
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
const {
selectedElementId,
@@ -140,19 +188,44 @@ const VideoPreview = ({
);
} else if (element.type === 'text') {
return (
<Group key={element.id}>
<Group key={`${element.id}-${fontsLoaded}`}>
<Text
ref={(node) => {
if (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}
x={element.x}
y={element.y}
fontSize={element.fontSize}
fontStyle={getTextFontStyle(element)}
fontFamily={element.fontFamily || 'Arial'}
fontStyle={getFontStyle(element)} // Use centralized function
fontFamily={element.fontFamily || 'Montserrat'}
fill={element.fill || '#ffffff'}
stroke={element.strokeWidth > 0 ? element.stroke || '#000000' : undefined}
strokeWidth={element.strokeWidth * 3 || 0}
@@ -246,15 +319,16 @@ const VideoPreview = ({
{/* Watermark - only show when watermarked is true */}
{watermarked && (
<Text
key={`watermark-${fontsLoaded}`}
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}
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

View File

@@ -1,5 +1,8 @@
// video-preview-utils.js
// Import centralized font management
import { getFontStyle } from '@/modules/editor/fonts';
// Snap settings
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;
};
// Helper function to get font style for text elements
export const getTextFontStyle = (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';
}
};
// Re-export the centralized font style function for backward compatibility
export { getFontStyle as getTextFontStyle };
// Check if element uses center-offset positioning
export const usesCenterPositioning = (elementType) => {

View File

@@ -8,19 +8,8 @@ import useVideoEditorStore from '@/stores/VideoEditorStore';
import { Bold, Italic, Minus, Plus, Type } from 'lucide-react';
import { useEffect, useState } from 'react';
// Font configuration - extensible for adding more fonts
const AVAILABLE_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',
},
},
];
// Import centralized font management
import { DEFAULT_TEXT_CONFIG, getAvailableFonts, loadFonts } from '@/modules/editor/fonts';
export default function TextSidebar({ isOpen, onClose }) {
const { selectedTextElement } = useVideoEditorStore();
@@ -44,17 +33,41 @@ export default function TextSidebar({ isOpen, onClose }) {
const MAX_STROKE_WIDTH = 3;
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
useEffect(() => {
if (selectedTextElement) {
setTextValue(selectedTextElement.text || '');
setFontSize(selectedTextElement.fontSize || 24);
setFontSize(selectedTextElement.fontSize || DEFAULT_TEXT_CONFIG.fontSize);
setIsBold(selectedTextElement.fontWeight === 'bold' || selectedTextElement.fontWeight === 700 || true);
setIsItalic(selectedTextElement.fontStyle === 'italic' || false);
setFontFamily(selectedTextElement.fontFamily || 'Montserrat');
setFillColor(selectedTextElement.fill || '#ffffff');
setStrokeColor(selectedTextElement.stroke || '#000000');
setStrokeWidth(selectedTextElement.strokeWidth || 2);
setFontFamily(selectedTextElement.fontFamily || DEFAULT_TEXT_CONFIG.fontFamily);
setFillColor(selectedTextElement.fill || DEFAULT_TEXT_CONFIG.fill);
setStrokeColor(selectedTextElement.stroke || DEFAULT_TEXT_CONFIG.stroke);
setStrokeWidth(selectedTextElement.strokeWidth || DEFAULT_TEXT_CONFIG.strokeWidth);
}
}, [selectedTextElement]);
@@ -231,11 +244,11 @@ export default function TextSidebar({ isOpen, onClose }) {
<SelectValue placeholder="Select font" />
</SelectTrigger>
<SelectContent>
{AVAILABLE_FONTS.map((font) => (
{availableFonts.map((font) => (
<SelectItem key={font.value} value={font.value}>
<span
style={{
fontFamily: font.name,
fontFamily: font.value,
fontWeight: isBold ? 'bold' : 'normal',
fontStyle: isItalic ? 'italic' : 'normal',
}}

View File

@@ -55,7 +55,7 @@
"y": 200,
"fontSize": 40,
"fontWeight": "bold",
"fontFamily": "Arial",
"fontFamily": "Montserrat",
"fontStyle": "normal",
"fill": "#ffffff",
"stroke": "#000000",

View File

@@ -77,6 +77,21 @@ export const generateTimelineFromTemplate = (dimensions, template, mediaStoreDat
case 'caption':
if (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 {
return null; // Skip if no caption
}

View File

@@ -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') {
Object.assign(Ziggy.routes, window.Ziggy.routes);
}