From c62e59b8f1ca4ea9ebc87fe9581e055cb8e7fa45 Mon Sep 17 00:00:00 2001 From: michaelschal <35957947-michaelschal@users.noreply.replit.com> Date: Mon, 17 Nov 2025 14:32:00 +0000 Subject: [PATCH 1/8] Transitioned from Plan to Build mode Replit-Commit-Author: Agent Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: bfc0ec91-5125-4b4f-a454-1e475a200a87 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/f2EnhiB From b581050eaeabac848a266badbf4293c9c8a95ad8 Mon Sep 17 00:00:00 2001 From: michaelschal <35957947-michaelschal@users.noreply.replit.com> Date: Mon, 17 Nov 2025 14:39:41 +0000 Subject: [PATCH 2/8] Add ability to overlay text on images for social media stories Integrate image processing to add text to story images with dynamic font sizing and text wrapping, and upload generated images to Cloudinary via a new service. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 4277243d-e609-4ee4-8cbc-3521773fc333 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/f2EnhiB --- server/services/cloudinary.ts | 33 ++++++++ server/services/facebook.ts | 14 +++- server/services/imageProcessor.ts | 121 ++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 1 deletion(-) 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(); From ec104a6d35609fa164a984b87b1b49b81a42d4b0 Mon Sep 17 00:00:00 2001 From: michaelschal <35957947-michaelschal@users.noreply.replit.com> Date: Mon, 17 Nov 2025 14:40:57 +0000 Subject: [PATCH 3/8] Add adaptive text overlay to story posts with smart sizing Implement automatic text overlay for stories, burning text into the bottom 25% of the image with adaptive font sizing (24-72px) using node-canvas, preserving aspect ratio, wrapping text, truncating long text, and adding a semi-transparent black background. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 746e211c-5491-469d-b1ad-02dd831ee078 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/f2EnhiB --- replit.md | 1 + 1 file changed, 1 insertion(+) 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 From 7618fac203c379393637082acc4dcdedef4f34bd Mon Sep 17 00:00:00 2001 From: michaelschal <35957947-michaelschal@users.noreply.replit.com> Date: Mon, 17 Nov 2025 14:53:35 +0000 Subject: [PATCH 4/8] Transitioned from Plan to Build mode Replit-Commit-Author: Agent Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: ec45be84-c7fb-4a71-a60a-633e77a5097c Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/f2EnhiB From 2f0ed931fad2fc96fa4378299d39b514a59157c2 Mon Sep 17 00:00:00 2001 From: michaelschal <35957947-michaelschal@users.noreply.replit.com> Date: Mon, 17 Nov 2025 14:54:53 +0000 Subject: [PATCH 5/8] Add adaptive text rendering to Instagram story previews Refactors the `PreviewModal` component to include a `calculateAdaptiveFontSize` function and `wrapText` utility, enabling dynamic adjustment of text size and line wrapping within the Instagram story preview to fit the designated area. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 443db3e2-8ade-4cee-99af-2eef0199807d Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/5aomXd9 --- client/src/components/preview-modal.tsx | 214 +++++++++++++++++------- 1 file changed, 157 insertions(+), 57 deletions(-) diff --git a/client/src/components/preview-modal.tsx b/client/src/components/preview-modal.tsx index b47ee99..1b09afe 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'; @@ -222,64 +222,164 @@ 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 renderInstagramStory = () => { + 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 */} +
+
+ +
+
+
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}
+ ))} +
+
+ )} +
+ )} +
+ ); + }; return ( From 71dab392a5fe4a2127477c0f4a8a87e032ddb2b4 Mon Sep 17 00:00:00 2001 From: michaelschal <35957947-michaelschal@users.noreply.replit.com> Date: Mon, 17 Nov 2025 15:01:16 +0000 Subject: [PATCH 6/8] Add support for previewing Facebook stories to the application Update PreviewFormat type in client/src/components/preview-modal.tsx to include 'facebook-story'. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 854cb32d-56d3-4e12-a339-2a0591f05161 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/5aomXd9 --- client/src/components/preview-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/preview-modal.tsx b/client/src/components/preview-modal.tsx index 1b09afe..b0d34e3 100644 --- a/client/src/components/preview-modal.tsx +++ b/client/src/components/preview-modal.tsx @@ -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, From 38a5d644f8fa55f609a287f976ce3ef291f47ec3 Mon Sep 17 00:00:00 2001 From: michaelschal <35957947-michaelschal@users.noreply.replit.com> Date: Mon, 17 Nov 2025 15:02:43 +0000 Subject: [PATCH 7/8] Add ability to preview Facebook stories alongside Instagram Refactor `renderInstagramStory` to a generic `renderStory` function that accepts a platform type, and add support for rendering Facebook stories. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: ee00a69b-0c5c-4d62-821b-8151fe745045 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/5aomXd9 --- client/src/components/preview-modal.tsx | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/client/src/components/preview-modal.tsx b/client/src/components/preview-modal.tsx index b0d34e3..ffd6278 100644 --- a/client/src/components/preview-modal.tsx +++ b/client/src/components/preview-modal.tsx @@ -293,7 +293,7 @@ export function PreviewModal({ return { fontSize, lines, isTruncated }; }; - const renderInstagramStory = () => { + const renderStory = (platform: 'facebook' | 'instagram') => { const STORY_WIDTH = 360; const STORY_HEIGHT = 640; const TEXT_BOX_HEIGHT_RATIO = 0.25; @@ -311,9 +311,15 @@ export function PreviewModal({
{/* Header */}
-
- -
+ {platform === 'instagram' ? ( +
+ +
+ ) : ( +
+ +
+ )}
votre_page
@@ -381,6 +387,9 @@ export function PreviewModal({ ); }; + const renderFacebookStory = () => renderStory('facebook'); + const renderInstagramStory = () => renderStory('instagram'); + return ( @@ -406,6 +415,9 @@ export function PreviewModal({ Feed Facebook (1200x630) + + Story Facebook (1080x1920) + Feed Instagram (1080x1080) @@ -419,6 +431,7 @@ export function PreviewModal({ {/* Preview */}
{previewFormat === 'facebook-feed' && renderFacebookFeed()} + {previewFormat === 'facebook-story' && renderFacebookStory()} {previewFormat === 'instagram-feed' && renderInstagramFeed()} {previewFormat === 'instagram-story' && renderInstagramStory()}
From c679bfe875818e30b81a22e88289d6cd63f466e9 Mon Sep 17 00:00:00 2001 From: michaelschal <35957947-michaelschal@users.noreply.replit.com> Date: Mon, 17 Nov 2025 15:03:06 +0000 Subject: [PATCH 8/8] Saved progress at the end of the loop Replit-Commit-Author: Agent Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: b3d9f3f8-aa20-4a95-8f4b-8313a738729b Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/5aomXd9