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