From 891b37450076b1419545a03e7e2db91b22479d0f Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 23 Jan 2026 16:27:27 +0100 Subject: [PATCH] feat: support large file uploads up to 4GB (disk storage + streaming) --- client/src/pages/new-reel.tsx | 2 +- server/routes.ts | 40 ++++++++++++++++++++---- server/services/cloudinary.ts | 58 +++++++++++++++++++++-------------- task.md | 6 ++++ 4 files changed, 76 insertions(+), 30 deletions(-) diff --git a/client/src/pages/new-reel.tsx b/client/src/pages/new-reel.tsx index c4189a6..86a8004 100644 --- a/client/src/pages/new-reel.tsx +++ b/client/src/pages/new-reel.tsx @@ -237,7 +237,7 @@ export default function NewReel() { 'video/x-msvideo': ['.avi'], 'video/*': [], // Fallback }, - maxSize: 500 * 1024 * 1024, // 500MB pour supporter les vidéos 4K + maxSize: 4 * 1024 * 1024 * 1024, // 4GB Limit noClick: true, noKeyboard: true, }); diff --git a/server/routes.ts b/server/routes.ts index ee1c91c..6a1bf93 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1,5 +1,7 @@ import type { Express, Request, Response, NextFunction } from "express"; import { createServer, type Server } from "http"; +import fs from "fs"; +import os from "os"; import { storage } from "./storage"; import { db } from "./db"; import multer from "multer"; @@ -20,14 +22,25 @@ const ALLOWED_MIME_TYPES = [ ]; // Configuration multer avec validation de taille et type +// Configuration multer avec validation de taille (4GB) et stockage disque temporaire const upload = multer({ - storage: multer.memoryStorage(), + storage: multer.diskStorage({ + destination: os.tmpdir(), + filename: (req, file, cb) => { + // Nettoyer le nom de fichier pour éviter les problèmes d'encodage + const safeName = file.originalname.replace(/[^a-zA-Z0-9.]/g, '_'); + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + cb(null, uniqueSuffix + '-' + safeName); + } + }), limits: { - fileSize: 50 * 1024 * 1024, // 50MB max + fileSize: 4 * 1024 * 1024 * 1024, // 4GB max files: 10 // 10 fichiers max }, fileFilter: (req, file, cb) => { - if (ALLOWED_MIME_TYPES.includes(file.mimetype)) { + // Accepter plus de formats vidéo si nécessaire (ex: mkv, avi) pour le transcodage + // Mais on garde les limites actuelles pour l'instant + if (ALLOWED_MIME_TYPES.includes(file.mimetype) || file.mimetype === 'application/octet-stream') { // iOS envoie parfois octet-stream cb(null, true); } else { cb(new Error(`Type de fichier non autorisé: ${file.mimetype}`)); @@ -498,6 +511,8 @@ export async function registerRoutes(app: Express): Promise { return res.status(400).json({ error: "No file uploaded" }); } + console.log(`📂 Processing upload: ${req.file.originalname} (${(req.file.size / 1024 / 1024).toFixed(2)} MB)`); + const user = req.user as User; const userId = user.id; @@ -505,22 +520,29 @@ export async function registerRoutes(app: Express): Promise { const cloudinaryConfig = await storage.getAnyCloudinaryConfig(); if (!cloudinaryConfig) { + // Nettoyer le fichier temporaire + await fs.promises.unlink(req.file.path).catch(console.error); return res.status(400).json({ error: "Cloudinary not configured. Please ask an administrator to configure Cloudinary in Settings first." }); } - // Upload to Cloudinary (service will use shared config internally) + // Upload to Cloudinary using file path (streamed from disk) + // req.file.path est disponible avec DiskStorage const uploadResult = await cloudinaryService.uploadMedia( - req.file.buffer, + req.file.path, req.file.originalname, userId, req.file.mimetype ); + // Clean up temp file immediately after upload to free disk space + await fs.promises.unlink(req.file.path).catch(err => console.error("Failed to cleanup temp file:", err)); + const mediaItem = await storage.createMedia({ userId, - type: req.file.mimetype.startsWith("video/") ? "video" : "image", + // Détection basique basée sur le mimetype ou le résultat Cloudinary + type: req.file.mimetype.startsWith("video/") || req.file.originalname.match(/\.(mp4|mov|avi|mkv)$/i) ? "video" : "image", cloudinaryPublicId: uploadResult.publicId, originalUrl: uploadResult.originalUrl, facebookFeedUrl: uploadResult.facebookFeedUrl, @@ -533,6 +555,12 @@ export async function registerRoutes(app: Express): Promise { res.json(mediaItem); } catch (error) { console.error("Error uploading media:", error); + + // Attempt cleanup on error + if (req.file && req.file.path) { + await fs.promises.unlink(req.file.path).catch(() => { }); + } + const errorMessage = error instanceof Error ? error.message : "Failed to upload media"; res.status(500).json({ error: errorMessage }); } diff --git a/server/services/cloudinary.ts b/server/services/cloudinary.ts index 3b5431b..b44b153 100644 --- a/server/services/cloudinary.ts +++ b/server/services/cloudinary.ts @@ -3,7 +3,7 @@ import { storage } from '../storage'; class CloudinaryService { async uploadMedia( - buffer: Buffer, + file: Buffer | string, fileName: string, userId: string, mimeType: string @@ -16,7 +16,7 @@ class CloudinaryService { }> { // Get any available Cloudinary config (shared across all users) const config = await storage.getAnyCloudinaryConfig(); - + if (!config) { throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); } @@ -32,30 +32,42 @@ class CloudinaryService { const isVideo = mimeType.startsWith('video/'); // Define eager transformations for images (generated during upload) + // Note: Eager transformations are not applied to videos in this flow to avoid timeouts or huge processing const eagerTransformations = !isVideo ? [ { width: 1080, height: 1080, crop: 'fill', gravity: 'center' }, // Facebook Feed (square, no borders) { width: 1080, height: 1080, crop: 'pad', gravity: 'center', background: 'auto' }, // Instagram Feed { width: 1080, height: 1920, crop: 'pad', gravity: 'center', background: 'auto' } // Instagram Story ] : undefined; - // Upload to Cloudinary with eager transformations - const uploadResult = await new Promise((resolve, reject) => { - const uploadStream = cloudinary.uploader.upload_stream( - { - folder: 'social-flow', - public_id: `${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`, - resource_type: isVideo ? 'video' : 'image', - eager: eagerTransformations, - eager_async: false, // Generate transformations immediately - }, - (error, result) => { - if (error) reject(error); - else resolve(result); - } - ); + const uploadOptions = { + folder: 'social-flow', + public_id: `${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`, + resource_type: isVideo ? 'video' : 'image', + eager: eagerTransformations, + eager_async: false, // Generate transformations immediately + }; - uploadStream.end(buffer); - }); + let uploadResult; + + if (typeof file === 'string') { + // Upload from file path (streamed by SDK) + uploadResult = await cloudinary.uploader.upload(file, { + ...uploadOptions, + resource_type: isVideo ? 'video' : 'image', // Type assertion for SDK + } as any); + } else { + // Upload from buffer (memory) + uploadResult = await new Promise((resolve, reject) => { + const uploadStream = cloudinary.uploader.upload_stream( + uploadOptions as any, + (error, result) => { + if (error) reject(error); + else resolve(result); + } + ); + uploadStream.end(file); + }); + } const publicId = uploadResult.public_id; const originalUrl = uploadResult.secure_url; @@ -83,7 +95,7 @@ class CloudinaryService { async deleteMedia(publicId: string, userId: string, mediaType: 'image' | 'video'): Promise { // Get any available Cloudinary config (shared across all users) const config = await storage.getAnyCloudinaryConfig(); - + if (!config) { throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); } @@ -102,7 +114,7 @@ class CloudinaryService { 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.'); } @@ -135,7 +147,7 @@ class CloudinaryService { async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> { const config = await storage.getAnyCloudinaryConfig(); - + if (!config) { throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); } @@ -171,7 +183,7 @@ class CloudinaryService { async deleteLogo(publicId: 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.'); } diff --git a/task.md b/task.md index 2152817..ba2af26 100644 --- a/task.md +++ b/task.md @@ -97,6 +97,12 @@ Ajout d'une fonctionnalité complète de création de Reels Facebook permettant - [x] Afficher les étapes (Upload, Audio, Stabilisation, Encodage) - [x] Estimer le temps restant selon les options activées (Stabilisation = +30s) +### Phase 9: Maintenance & Bug Fixes 🛠️ + +- [x] **Fix API 500** (`server/routes/reels.ts`) : Variable `stabilize` manquante corrigée. +- [x] **Fix iOS Upload** (`client/src/pages/new-reel.tsx`) : Ajout support `.mov` et augmentation limite taille (500MB). +- [ ] **Support Gros Fichiers (4GB)** : Passage à DiskStorage et streaming Cloudinary. + ## Progress Log - **22 Jan 2026** - Analyse complète et PRD créé