mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-24 07:28:11 +02:00
feat: support large file uploads up to 4GB (disk storage + streaming)
This commit is contained in:
1 parent
427c0b290e
commit
891b374500
4 files changed
+76
-30
No files matched your search
@@ -237,7 +237,7 @@ export default function NewReel() {
|
|||||||
'video/x-msvideo': ['.avi'],
|
'video/x-msvideo': ['.avi'],
|
||||||
'video/*': [], // Fallback
|
'video/*': [], // Fallback
|
||||||
},
|
},
|
||||||
maxSize: 500 * 1024 * 1024, // 500MB pour supporter les vidéos 4K
|
maxSize: 4 * 1024 * 1024 * 1024, // 4GB Limit
|
||||||
noClick: true,
|
noClick: true,
|
||||||
noKeyboard: true,
|
noKeyboard: true,
|
||||||
});
|
});
|
||||||
|
|||||||
+34
-6
@@ -1,5 +1,7 @@
|
|||||||
import type { Express, Request, Response, NextFunction } from "express";
|
import type { Express, Request, Response, NextFunction } from "express";
|
||||||
import { createServer, type Server } from "http";
|
import { createServer, type Server } from "http";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import multer from "multer";
|
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 et type
|
||||||
|
// Configuration multer avec validation de taille (4GB) et stockage disque temporaire
|
||||||
const upload = multer({
|
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: {
|
limits: {
|
||||||
fileSize: 50 * 1024 * 1024, // 50MB max
|
fileSize: 4 * 1024 * 1024 * 1024, // 4GB max
|
||||||
files: 10 // 10 fichiers max
|
files: 10 // 10 fichiers max
|
||||||
},
|
},
|
||||||
fileFilter: (req, file, cb) => {
|
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);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error(`Type de fichier non autorisé: ${file.mimetype}`));
|
cb(new Error(`Type de fichier non autorisé: ${file.mimetype}`));
|
||||||
@@ -498,6 +511,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
return res.status(400).json({ error: "No file uploaded" });
|
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 user = req.user as User;
|
||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
|
|
||||||
@@ -505,22 +520,29 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
const cloudinaryConfig = await storage.getAnyCloudinaryConfig();
|
const cloudinaryConfig = await storage.getAnyCloudinaryConfig();
|
||||||
|
|
||||||
if (!cloudinaryConfig) {
|
if (!cloudinaryConfig) {
|
||||||
|
// Nettoyer le fichier temporaire
|
||||||
|
await fs.promises.unlink(req.file.path).catch(console.error);
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "Cloudinary not configured. Please ask an administrator to configure Cloudinary in Settings first."
|
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(
|
const uploadResult = await cloudinaryService.uploadMedia(
|
||||||
req.file.buffer,
|
req.file.path,
|
||||||
req.file.originalname,
|
req.file.originalname,
|
||||||
userId,
|
userId,
|
||||||
req.file.mimetype
|
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({
|
const mediaItem = await storage.createMedia({
|
||||||
userId,
|
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,
|
cloudinaryPublicId: uploadResult.publicId,
|
||||||
originalUrl: uploadResult.originalUrl,
|
originalUrl: uploadResult.originalUrl,
|
||||||
facebookFeedUrl: uploadResult.facebookFeedUrl,
|
facebookFeedUrl: uploadResult.facebookFeedUrl,
|
||||||
@@ -533,6 +555,12 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
res.json(mediaItem);
|
res.json(mediaItem);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error uploading media:", 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";
|
const errorMessage = error instanceof Error ? error.message : "Failed to upload media";
|
||||||
res.status(500).json({ error: errorMessage });
|
res.status(500).json({ error: errorMessage });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { storage } from '../storage';
|
|||||||
|
|
||||||
class CloudinaryService {
|
class CloudinaryService {
|
||||||
async uploadMedia(
|
async uploadMedia(
|
||||||
buffer: Buffer,
|
file: Buffer | string,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
mimeType: string
|
mimeType: string
|
||||||
@@ -16,7 +16,7 @@ class CloudinaryService {
|
|||||||
}> {
|
}> {
|
||||||
// Get any available Cloudinary config (shared across all users)
|
// Get any available Cloudinary config (shared across all users)
|
||||||
const config = await storage.getAnyCloudinaryConfig();
|
const config = await storage.getAnyCloudinaryConfig();
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
|
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/');
|
const isVideo = mimeType.startsWith('video/');
|
||||||
|
|
||||||
// Define eager transformations for images (generated during upload)
|
// 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 ? [
|
const eagerTransformations = !isVideo ? [
|
||||||
{ width: 1080, height: 1080, crop: 'fill', gravity: 'center' }, // Facebook Feed (square, no borders)
|
{ 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: 1080, crop: 'pad', gravity: 'center', background: 'auto' }, // Instagram Feed
|
||||||
{ width: 1080, height: 1920, crop: 'pad', gravity: 'center', background: 'auto' } // Instagram Story
|
{ width: 1080, height: 1920, crop: 'pad', gravity: 'center', background: 'auto' } // Instagram Story
|
||||||
] : undefined;
|
] : undefined;
|
||||||
|
|
||||||
// Upload to Cloudinary with eager transformations
|
const uploadOptions = {
|
||||||
const uploadResult = await new Promise<any>((resolve, reject) => {
|
folder: 'social-flow',
|
||||||
const uploadStream = cloudinary.uploader.upload_stream(
|
public_id: `${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`,
|
||||||
{
|
resource_type: isVideo ? 'video' : 'image',
|
||||||
folder: 'social-flow',
|
eager: eagerTransformations,
|
||||||
public_id: `${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`,
|
eager_async: false, // Generate transformations immediately
|
||||||
resource_type: isVideo ? 'video' : 'image',
|
};
|
||||||
eager: eagerTransformations,
|
|
||||||
eager_async: false, // Generate transformations immediately
|
|
||||||
},
|
|
||||||
(error, result) => {
|
|
||||||
if (error) reject(error);
|
|
||||||
else resolve(result);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
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<any>((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 publicId = uploadResult.public_id;
|
||||||
const originalUrl = uploadResult.secure_url;
|
const originalUrl = uploadResult.secure_url;
|
||||||
@@ -83,7 +95,7 @@ class CloudinaryService {
|
|||||||
async deleteMedia(publicId: string, userId: string, mediaType: 'image' | 'video'): Promise<void> {
|
async deleteMedia(publicId: string, userId: string, mediaType: 'image' | 'video'): Promise<void> {
|
||||||
// Get any available Cloudinary config (shared across all users)
|
// Get any available Cloudinary config (shared across all users)
|
||||||
const config = await storage.getAnyCloudinaryConfig();
|
const config = await storage.getAnyCloudinaryConfig();
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
|
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<string> {
|
async uploadStoryImageWithText(buffer: Buffer, originalFileName: string): Promise<string> {
|
||||||
const config = await storage.getAnyCloudinaryConfig();
|
const config = await storage.getAnyCloudinaryConfig();
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
|
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 }> {
|
async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
|
||||||
const config = await storage.getAnyCloudinaryConfig();
|
const config = await storage.getAnyCloudinaryConfig();
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
|
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<void> {
|
async deleteLogo(publicId: string): Promise<void> {
|
||||||
const config = await storage.getAnyCloudinaryConfig();
|
const config = await storage.getAnyCloudinaryConfig();
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
|
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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] Afficher les étapes (Upload, Audio, Stabilisation, Encodage)
|
||||||
- [x] Estimer le temps restant selon les options activées (Stabilisation = +30s)
|
- [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
|
## Progress Log
|
||||||
|
|
||||||
- **22 Jan 2026** - Analyse complète et PRD créé
|
- **22 Jan 2026** - Analyse complète et PRD créé
|
||||||
|
|||||||
Reference in new issue
Block a user