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] 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();