diff --git a/client/src/components/preview-modal.tsx b/client/src/components/preview-modal.tsx index b47ee99..ffd6278 100644 --- a/client/src/components/preview-modal.tsx +++ b/client/src/components/preview-modal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -16,7 +16,7 @@ interface PreviewModalProps { readOnly?: boolean; } -type PreviewFormat = 'facebook-feed' | 'instagram-feed' | 'instagram-story'; +type PreviewFormat = 'facebook-feed' | 'facebook-story' | 'instagram-feed' | 'instagram-story'; export function PreviewModal({ open, @@ -222,64 +222,173 @@ export function PreviewModal({ ); - const renderInstagramStory = () => ( -
- {/* Header */} -
-
- -
-
-
votre_page
-
-
+ const wrapText = (text: string, maxWidth: number, fontSize: number): string[] => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return [text]; - {/* Media */} - {orderedMedia.length > 0 && ( -
- {renderMedia(orderedMedia[currentPhotoIndex], "w-full h-full object-cover")} - {orderedMedia.length > 1 && ( - <> -
- {orderedMedia.map((_, idx) => ( -
- - - - )} - {/* Text overlay */} - {postText && ( -
-
-

{postText}

-
+ ctx.font = `bold ${fontSize}px Arial, sans-serif`; + const words = text.split(' '); + const lines: string[] = []; + let currentLine = ''; + + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + const metrics = ctx.measureText(testLine); + + if (metrics.width > maxWidth && currentLine) { + lines.push(currentLine); + currentLine = word; + } else { + currentLine = testLine; + } + } + + if (currentLine) { + lines.push(currentLine); + } + + return lines; + }; + + const calculateAdaptiveFontSize = (text: string, containerWidth: number, containerHeight: number): { fontSize: number; lines: string[]; isTruncated: boolean } => { + const MIN_FONT_SIZE = 24; + const MAX_FONT_SIZE = 72; + const LINE_HEIGHT_MULTIPLIER = 1.2; + + let fontSize = MAX_FONT_SIZE; + let lines: string[] = []; + let totalTextHeight = 0; + + while (fontSize >= MIN_FONT_SIZE) { + lines = wrapText(text, containerWidth, fontSize); + const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; + totalTextHeight = lines.length * lineHeight; + + if (totalTextHeight <= containerHeight) { + break; + } + + fontSize -= 2; + } + + let isTruncated = false; + + if (fontSize < MIN_FONT_SIZE) { + fontSize = MIN_FONT_SIZE; + lines = wrapText(text, containerWidth, fontSize); + const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; + totalTextHeight = lines.length * lineHeight; + + const maxLines = Math.floor(containerHeight / lineHeight); + if (lines.length > maxLines) { + lines = lines.slice(0, maxLines); + if (lines[maxLines - 1]) { + lines[maxLines - 1] = lines[maxLines - 1].substring(0, lines[maxLines - 1].length - 3) + '...'; + } + isTruncated = true; + } + } + + return { fontSize, lines, isTruncated }; + }; + + const renderStory = (platform: 'facebook' | 'instagram') => { + const STORY_WIDTH = 360; + const STORY_HEIGHT = 640; + const TEXT_BOX_HEIGHT_RATIO = 0.25; + const PADDING = 16; + + const textBoxHeight = STORY_HEIGHT * TEXT_BOX_HEIGHT_RATIO; + const maxTextWidth = STORY_WIDTH - (PADDING * 2); + const maxTextHeight = textBoxHeight - (PADDING * 2); + + const { fontSize, lines } = postText + ? calculateAdaptiveFontSize(postText, maxTextWidth, maxTextHeight) + : { fontSize: 24, lines: [] }; + + return ( +
+ {/* Header */} +
+ {platform === 'instagram' ? ( +
+ +
+ ) : ( +
+
)} +
+
votre_page
+
- )} -
- ); + + {/* Media */} + {orderedMedia.length > 0 && ( +
+ {renderMedia(orderedMedia[currentPhotoIndex], "w-full h-full object-cover")} + {orderedMedia.length > 1 && ( + <> +
+ {orderedMedia.map((_, idx) => ( +
+ + + + )} + + {/* Text overlay box - matching exact server rendering */} + {postText && ( +
+
+
+ {lines.map((line, idx) => ( +
{line}
+ ))} +
+
+ )} +
+ )} +
+ ); + }; + + const renderFacebookStory = () => renderStory('facebook'); + const renderInstagramStory = () => renderStory('instagram'); return ( @@ -306,6 +415,9 @@ export function PreviewModal({ Feed Facebook (1200x630) + + Story Facebook (1080x1920) + Feed Instagram (1080x1080) @@ -319,6 +431,7 @@ export function PreviewModal({ {/* Preview */}
{previewFormat === 'facebook-feed' && renderFacebookFeed()} + {previewFormat === 'facebook-story' && renderFacebookStory()} {previewFormat === 'instagram-feed' && renderInstagramFeed()} {previewFormat === 'instagram-story' && renderInstagramStory()}
diff --git a/replit.md b/replit.md index 073508f..1bba0e4 100644 --- a/replit.md +++ b/replit.md @@ -11,6 +11,7 @@ Preferred communication style: Simple, everyday language. ## Recent Changes ### November 17, 2025 +- **Story Text Overlay with Adaptive Sizing**: Implemented automatic text overlay on Instagram and Facebook stories with intelligent font size adaptation. When publishing a story with text content, the system now generates a composite image with the text burned into the bottom 25% of the image. Features: (1) Adaptive font sizing (24-72px) that automatically adjusts based on text length to fit within the text box, (2) Aspect ratio preservation for source images (centered with black letterboxing if needed to maintain 9:16 story format), (3) Automatic text wrapping with line breaks on word boundaries, (4) Ellipsis truncation for extremely long text that exceeds available space, (5) Semi-transparent black background (60% opacity) behind text for readability. Implementation uses Canvas (node-canvas) for image composition in `imageProcessor.addTextToStoryImage()`, with the generated image uploaded to Cloudinary in a dedicated `social-flow/stories` folder before publication. This ensures text appears correctly on published stories since Facebook/Instagram APIs don't support text overlays natively. The text box reserves 25% of the image height with 40px padding, and font size reduces in 2px increments until text fits or reaches the 24px minimum. - **AI Assistant Model Loading Fix**: Fixed bug preventing non-admin users from accessing the AI assistant feature. Problem: The AI chat component (`ai-chat.tsx`) was querying `/api/ai/models` which requires admin privileges (`requireAdmin`), causing model list to fail loading for regular users. Solution: Changed the component to use `/api/openrouter/models` endpoint instead, which only requires authentication (`requireAuth`). Updated data structure access from `modelsData?.models` to `modelsData?.data` to match the OpenRouter API response format `{ data: OpenRouterModel[] }`. This fix enables all authenticated users to load the model list and use the AI assistant feature. The endpoint `/api/openrouter/models` was already being used in Settings page for the same purpose, ensuring consistency across the application. ### November 5, 2025 diff --git a/server/services/cloudinary.ts b/server/services/cloudinary.ts index 4561317..292144b 100644 --- a/server/services/cloudinary.ts +++ b/server/services/cloudinary.ts @@ -99,6 +99,39 @@ class CloudinaryService { resource_type: mediaType, }); } + + async uploadStoryImageWithText(buffer: Buffer, originalFileName: string): Promise { + const config = await storage.getAnyCloudinaryConfig(); + + if (!config) { + throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); + } + + cloudinary.config({ + cloud_name: config.cloudName, + api_key: config.apiKey, + api_secret: config.apiSecret, + secure: true, + }); + + const uploadResult = await new Promise((resolve, reject) => { + const uploadStream = cloudinary.uploader.upload_stream( + { + folder: 'social-flow/stories', + public_id: `story-${Date.now()}-${originalFileName.replace(/\.[^/.]+$/, '')}`, + resource_type: 'image', + }, + (error, result) => { + if (error) reject(error); + else resolve(result); + } + ); + + uploadStream.end(buffer); + }); + + return uploadResult.secure_url; + } } export const cloudinaryService = new CloudinaryService(); diff --git a/server/services/facebook.ts b/server/services/facebook.ts index 90395d9..7363932 100644 --- a/server/services/facebook.ts +++ b/server/services/facebook.ts @@ -1,5 +1,7 @@ import { storage } from '../storage'; import type { Post, SocialPage, Media } from '@shared/schema'; +import { imageProcessor } from './imageProcessor'; +import { cloudinaryService } from './cloudinary'; interface FacebookPhotoResponse { id: string; @@ -316,7 +318,17 @@ export class FacebookService { // Step 1: Upload photo as unpublished to get photo_id // Step 2: Publish the photo as a story using the photo_id - const photoUrl = media.originalUrl; + let photoUrl = media.originalUrl; + + if (post.content && post.content.trim().length > 0) { + try { + const imageWithText = await imageProcessor.addTextToStoryImage(media.originalUrl, post.content); + photoUrl = await cloudinaryService.uploadStoryImageWithText(imageWithText, `story-${media.id}.png`); + console.log('Story image with text generated:', photoUrl); + } catch (error) { + console.error('Error generating story image with text, using original:', error); + } + } // Step 1: Upload photo as unpublished const uploadParams = new URLSearchParams({ diff --git a/server/services/imageProcessor.ts b/server/services/imageProcessor.ts index 7f621f0..87e0de4 100644 --- a/server/services/imageProcessor.ts +++ b/server/services/imageProcessor.ts @@ -2,6 +2,7 @@ import sharp from "sharp"; import { randomUUID } from "crypto"; import path from "path"; import fs from "fs/promises"; +import { createCanvas, loadImage, registerFont } from "canvas"; interface CropDimensions { width: number; @@ -97,6 +98,126 @@ export class ImageProcessor { console.error("Error deleting file:", error); } } + + async addTextToStoryImage(imageUrl: string, text: string): Promise { + const STORY_WIDTH = 1080; + const STORY_HEIGHT = 1920; + const TEXT_BOX_HEIGHT_RATIO = 0.25; + const MIN_FONT_SIZE = 24; + const MAX_FONT_SIZE = 72; + const PADDING = 40; + const LINE_HEIGHT_MULTIPLIER = 1.3; + + const canvas = createCanvas(STORY_WIDTH, STORY_HEIGHT); + const ctx = canvas.getContext('2d'); + + const image = await loadImage(imageUrl); + + const imgWidth = image.width; + const imgHeight = image.height; + const targetRatio = STORY_WIDTH / STORY_HEIGHT; + const imgRatio = imgWidth / imgHeight; + + let drawWidth, drawHeight, offsetX, offsetY; + + if (imgRatio > targetRatio) { + drawHeight = STORY_HEIGHT; + drawWidth = imgWidth * (STORY_HEIGHT / imgHeight); + offsetX = (STORY_WIDTH - drawWidth) / 2; + offsetY = 0; + } else { + drawWidth = STORY_WIDTH; + drawHeight = imgHeight * (STORY_WIDTH / imgWidth); + offsetX = 0; + offsetY = (STORY_HEIGHT - drawHeight) / 2; + } + + ctx.fillStyle = 'black'; + ctx.fillRect(0, 0, STORY_WIDTH, STORY_HEIGHT); + ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight); + + const textBoxHeight = STORY_HEIGHT * TEXT_BOX_HEIGHT_RATIO; + const textBoxY = STORY_HEIGHT - textBoxHeight; + + ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.fillRect(0, textBoxY, STORY_WIDTH, textBoxHeight); + + const maxTextWidth = STORY_WIDTH - (PADDING * 2); + const maxTextHeight = textBoxHeight - (PADDING * 2); + + let fontSize = MAX_FONT_SIZE; + let lines: string[] = []; + let totalTextHeight = 0; + + while (fontSize >= MIN_FONT_SIZE) { + ctx.font = `bold ${fontSize}px Arial, sans-serif`; + + lines = this.wrapText(ctx, text, maxTextWidth); + const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; + totalTextHeight = lines.length * lineHeight; + + if (totalTextHeight <= maxTextHeight) { + break; + } + + fontSize -= 2; + } + + if (fontSize < MIN_FONT_SIZE) { + fontSize = MIN_FONT_SIZE; + ctx.font = `bold ${fontSize}px Arial, sans-serif`; + lines = this.wrapText(ctx, text, maxTextWidth); + const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; + totalTextHeight = lines.length * lineHeight; + + const maxLines = Math.floor(maxTextHeight / lineHeight); + if (lines.length > maxLines) { + lines = lines.slice(0, maxLines); + if (lines[maxLines - 1]) { + lines[maxLines - 1] = lines[maxLines - 1].substring(0, lines[maxLines - 1].length - 3) + '...'; + } + totalTextHeight = maxLines * lineHeight; + } + } + + ctx.fillStyle = 'white'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; + const startY = textBoxY + (textBoxHeight - totalTextHeight) / 2 + lineHeight / 2; + + lines.forEach((line, index) => { + const y = startY + (index * lineHeight); + ctx.fillText(line, STORY_WIDTH / 2, y); + }); + + return canvas.toBuffer('image/png'); + } + + private wrapText(ctx: any, text: string, maxWidth: number): string[] { + const words = text.split(' '); + const lines: string[] = []; + let currentLine = ''; + + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + const metrics = ctx.measureText(testLine); + + if (metrics.width > maxWidth && currentLine) { + lines.push(currentLine); + currentLine = word; + } else { + currentLine = testLine; + } + } + + if (currentLine) { + lines.push(currentLine); + } + + return lines; + } } export const imageProcessor = new ImageProcessor();