mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
Process image edits server-side for consistent results
Refactor client-side image editing to server-side processing using Sharp for applying overlays and text, ensuring consistency between the editor preview and saved image. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/Hpj2VzR
This commit is contained in:
1 parent
ef1185d886
commit
83dbd98c8e
2 files changed
+140
-134
No files matched your search
@@ -93,129 +93,40 @@ export default function ImageEditor() {
|
||||
const [ribbonPublicId, setRibbonPublicId] = useState<string | null>(null);
|
||||
const [isGeneratingRibbon, setIsGeneratingRibbon] = useState(false);
|
||||
|
||||
// Save edited image mutation
|
||||
// Save edited image mutation - Server-side processing with Sharp
|
||||
const saveImageMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedMedia) {
|
||||
throw new Error("Aucune image sélectionnée");
|
||||
}
|
||||
|
||||
// IMPORTANT: Always load the ORIGINAL image to preserve exact dimensions
|
||||
// Do NOT use previewUrl as it may have filters that change dimensions
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.src = selectedMedia.originalUrl;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
});
|
||||
|
||||
const width = img.naturalWidth;
|
||||
const height = img.naturalHeight;
|
||||
|
||||
// Create canvas at exact image size
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
// Draw the base image
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Draw ribbon if enabled
|
||||
if (ribbon.enabled && ribbon.text) {
|
||||
const ribbonSize = 150;
|
||||
const positions: Record<string, { x: number; y: number; rotation: number }> = {
|
||||
top_left: { x: 0, y: 0, rotation: 0 },
|
||||
top_right: { x: width - ribbonSize, y: 0, rotation: 90 },
|
||||
bottom_left: { x: 0, y: height - ribbonSize, rotation: -90 },
|
||||
bottom_right: { x: width - ribbonSize, y: height - ribbonSize, rotation: 180 }
|
||||
// Send overlay configuration to server - let server handle image processing
|
||||
const overlayConfig = {
|
||||
mediaId: selectedMedia.id,
|
||||
imageUrl: previewUrl || selectedMedia.originalUrl,
|
||||
ribbon: ribbon.enabled ? {
|
||||
text: ribbon.text,
|
||||
color: ribbon.color,
|
||||
position: ribbon.position
|
||||
} : null,
|
||||
priceBadge: priceBadge.enabled ? {
|
||||
price: priceBadge.price,
|
||||
color: priceBadge.color,
|
||||
position: priceBadge.position,
|
||||
size: priceBadge.size
|
||||
} : null
|
||||
};
|
||||
|
||||
const pos = positions[ribbon.position] || positions.top_left;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(pos.x + ribbonSize / 2, pos.y + ribbonSize / 2);
|
||||
ctx.rotate((pos.rotation * Math.PI) / 180);
|
||||
|
||||
// Draw triangle
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-ribbonSize / 2, -ribbonSize / 2);
|
||||
ctx.lineTo(ribbonSize / 2, ribbonSize / 2);
|
||||
ctx.lineTo(-ribbonSize / 2, ribbonSize / 2);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = ribbon.color === 'red' ? '#dc2626' : '#eab308';
|
||||
ctx.fill();
|
||||
|
||||
// Draw text
|
||||
ctx.rotate(-45 * Math.PI / 180);
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.font = `bold ${ribbon.text.length <= 5 ? 22 : ribbon.text.length <= 8 ? 18 : ribbon.text.length <= 11 ? 15 : 12}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(ribbon.text, 0, 0);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Draw price badge if enabled
|
||||
if (priceBadge.enabled && priceBadge.price) {
|
||||
const padding = 16;
|
||||
const badgeText = `€${priceBadge.price}`;
|
||||
ctx.font = `bold ${priceBadge.size}px sans-serif`;
|
||||
const textWidth = ctx.measureText(badgeText).width;
|
||||
const badgeWidth = textWidth + padding * 2;
|
||||
const badgeHeight = priceBadge.size + padding;
|
||||
|
||||
let x = padding;
|
||||
let y = padding;
|
||||
|
||||
if (priceBadge.position === 'north_east') {
|
||||
x = width - badgeWidth - padding;
|
||||
} else if (priceBadge.position === 'south_west') {
|
||||
y = height - badgeHeight - padding;
|
||||
} else if (priceBadge.position === 'south_east') {
|
||||
x = width - badgeWidth - padding;
|
||||
y = height - badgeHeight - padding;
|
||||
}
|
||||
|
||||
// Draw rounded rectangle background
|
||||
const radius = badgeHeight / 2;
|
||||
ctx.fillStyle = priceBadge.color === 'red' ? '#dc2626' : '#eab308';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + badgeWidth - radius, y);
|
||||
ctx.arc(x + badgeWidth - radius, y + radius, radius, -Math.PI / 2, Math.PI / 2);
|
||||
ctx.lineTo(x + radius, y + badgeHeight);
|
||||
ctx.arc(x + radius, y + radius, radius, Math.PI / 2, -Math.PI / 2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Draw text
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(badgeText, x + badgeWidth / 2, y + badgeHeight / 2);
|
||||
}
|
||||
|
||||
// Convert canvas to blob
|
||||
const blob = await new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob!), 'image/jpeg', 0.95);
|
||||
});
|
||||
|
||||
// Upload to server
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, `edited_${Date.now()}.jpg`);
|
||||
|
||||
const response = await fetch('/api/media/upload-edited', {
|
||||
const response = await fetch('/api/media/apply-overlays', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(overlayConfig),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Erreur lors de l'upload");
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Erreur lors de l'application des overlays");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
|
||||
+119
-24
@@ -1171,43 +1171,138 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Upload edited image with overlays
|
||||
app.post("/api/media/upload-edited", requireAuth, upload.single("file"), async (req, res) => {
|
||||
// Apply overlays to image using Sharp (server-side processing)
|
||||
app.post("/api/media/apply-overlays", requireAuth, async (req, res) => {
|
||||
try {
|
||||
console.log("📸 Receiving edited image upload...");
|
||||
|
||||
if (!req.file) {
|
||||
console.log("❌ No file in request");
|
||||
return res.status(400).json({ error: "No file uploaded" });
|
||||
}
|
||||
|
||||
console.log("📁 File received:", req.file.originalname, req.file.size, "bytes");
|
||||
|
||||
const { imageUrl, ribbon, priceBadge } = req.body;
|
||||
const user = req.user as User;
|
||||
const userId = user.id;
|
||||
|
||||
if (!imageUrl) {
|
||||
return res.status(400).json({ error: "Image URL required" });
|
||||
}
|
||||
|
||||
console.log("🎨 Applying overlays server-side with Sharp...");
|
||||
|
||||
// Download image from URL
|
||||
const imageResponse = await fetch(imageUrl);
|
||||
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||
|
||||
// Load with Sharp to process
|
||||
const sharp = (await import("sharp")).default;
|
||||
let image = sharp(imageBuffer);
|
||||
|
||||
// Get image metadata for dimensions
|
||||
const metadata = await image.metadata();
|
||||
const width = metadata.width!;
|
||||
const height = metadata.height!;
|
||||
|
||||
console.log(`📐 Image dimensions: ${width}x${height}`);
|
||||
|
||||
// Create SVG overlays
|
||||
const overlays: any[] = [];
|
||||
|
||||
// Add ribbon overlay
|
||||
if (ribbon) {
|
||||
const ribbonSize = 150;
|
||||
const positions: Record<string, { x: number; y: number; rotation: number }> = {
|
||||
top_left: { x: 0, y: 0, rotation: -45 },
|
||||
top_right: { x: width - ribbonSize, y: 0, rotation: 45 },
|
||||
bottom_left: { x: 0, y: height - ribbonSize, rotation: -135 },
|
||||
bottom_right: { x: width - ribbonSize, y: height - ribbonSize, rotation: 135 }
|
||||
};
|
||||
|
||||
const pos = positions[ribbon.position] || positions.top_left;
|
||||
const color = ribbon.color === 'red' ? '#dc2626' : '#eab308';
|
||||
const fontSize = ribbon.text.length <= 5 ? 22 : ribbon.text.length <= 8 ? 18 : ribbon.text.length <= 11 ? 15 : 12;
|
||||
|
||||
const ribbonSvg = `
|
||||
<svg width="${ribbonSize}" height="${ribbonSize}">
|
||||
<polygon points="0,0 ${ribbonSize},${ribbonSize} 0,${ribbonSize}" fill="${color}"/>
|
||||
<text x="${ribbonSize/2}" y="${ribbonSize/2}"
|
||||
font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold"
|
||||
fill="white" text-anchor="middle" dominant-baseline="middle"
|
||||
transform="rotate(${pos.rotation} ${ribbonSize/2} ${ribbonSize/2})">
|
||||
${ribbon.text}
|
||||
</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
overlays.push({
|
||||
input: Buffer.from(ribbonSvg),
|
||||
top: pos.y,
|
||||
left: pos.x
|
||||
});
|
||||
}
|
||||
|
||||
// Add price badge overlay
|
||||
if (priceBadge) {
|
||||
const padding = 16;
|
||||
const badgeText = `€${priceBadge.price}`;
|
||||
const fontSize = priceBadge.size;
|
||||
const textWidth = badgeText.length * fontSize * 0.6; // Approximate
|
||||
const badgeWidth = textWidth + padding * 2;
|
||||
const badgeHeight = fontSize + padding;
|
||||
const color = priceBadge.color === 'red' ? '#dc2626' : '#eab308';
|
||||
|
||||
let x = padding;
|
||||
let y = padding;
|
||||
|
||||
if (priceBadge.position === 'north_east') {
|
||||
x = width - badgeWidth - padding;
|
||||
} else if (priceBadge.position === 'south_west') {
|
||||
y = height - badgeHeight - padding;
|
||||
} else if (priceBadge.position === 'south_east') {
|
||||
x = width - badgeWidth - padding;
|
||||
y = height - badgeHeight - padding;
|
||||
}
|
||||
|
||||
const badgeSvg = `
|
||||
<svg width="${badgeWidth}" height="${badgeHeight}">
|
||||
<rect x="0" y="0" width="${badgeWidth}" height="${badgeHeight}"
|
||||
rx="${badgeHeight/2}" ry="${badgeHeight/2}" fill="${color}"/>
|
||||
<text x="${badgeWidth/2}" y="${badgeHeight/2}"
|
||||
font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold"
|
||||
fill="white" text-anchor="middle" dominant-baseline="middle">
|
||||
${badgeText}
|
||||
</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
overlays.push({
|
||||
input: Buffer.from(badgeSvg),
|
||||
top: y,
|
||||
left: x
|
||||
});
|
||||
}
|
||||
|
||||
// Apply all overlays
|
||||
if (overlays.length > 0) {
|
||||
image = image.composite(overlays);
|
||||
}
|
||||
|
||||
// Convert to buffer
|
||||
const outputBuffer = await image.jpeg({ quality: 95 }).toBuffer();
|
||||
|
||||
console.log("✅ Overlays applied, uploading to Cloudinary...");
|
||||
|
||||
// Check if Cloudinary is configured
|
||||
const cloudinaryConfig = await storage.getCloudinaryConfig(userId);
|
||||
|
||||
if (!cloudinaryConfig) {
|
||||
console.log("❌ Cloudinary not configured for user:", userId);
|
||||
return res.status(400).json({
|
||||
error: "Cloudinary not configured. Please configure Cloudinary in Settings first."
|
||||
});
|
||||
}
|
||||
|
||||
console.log("☁️ Uploading to Cloudinary...");
|
||||
// Upload edited image to Cloudinary
|
||||
// Upload to Cloudinary
|
||||
const uploadResult = await cloudinaryService.uploadMedia(
|
||||
req.file.buffer,
|
||||
outputBuffer,
|
||||
`edited_${Date.now()}.jpg`,
|
||||
userId,
|
||||
req.file.mimetype
|
||||
'image/jpeg'
|
||||
);
|
||||
|
||||
console.log("✅ Cloudinary upload success:", uploadResult.publicId);
|
||||
console.log("💾 Saving to database...");
|
||||
|
||||
// Save to database
|
||||
const mediaItem = await storage.createMedia({
|
||||
userId,
|
||||
type: "image",
|
||||
@@ -1217,14 +1312,14 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
instagramFeedUrl: uploadResult.instagramFeedUrl,
|
||||
instagramStoryUrl: uploadResult.instagramStoryUrl,
|
||||
fileName: `edited_${Date.now()}.jpg`,
|
||||
fileSize: req.file.size,
|
||||
fileSize: outputBuffer.length,
|
||||
});
|
||||
|
||||
console.log("✅ Media saved to DB with ID:", mediaItem.id);
|
||||
console.log("✅ Image saved with ID:", mediaItem.id);
|
||||
res.json(mediaItem);
|
||||
} catch (error) {
|
||||
console.error("💥 Error uploading edited image:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to upload edited image";
|
||||
console.error("💥 Error applying overlays:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to apply overlays";
|
||||
res.status(500).json({ error: errorMessage });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user