mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-14 10:48:20 +02:00
feat: Implement internal MP3 management and logo overlay functionality for Reels.
This commit is contained in:
1 parent
d89de20df2
commit
0e9622b5eb
12 files changed
+632
-8
No files matched your search
@@ -15,6 +15,7 @@ import type { User, InsertUser, ScheduledPost, FreesoundConfig } from "@shared/s
|
||||
import { freeSoundService } from "./services/freesound";
|
||||
import { analyticsRouter } from "./routes/analytics";
|
||||
import { reelsRouter } from "./routes/reels";
|
||||
import { insertAudioTrackSchema } from "@shared/schema";
|
||||
|
||||
// Types MIME autorisés pour les uploads
|
||||
const ALLOWED_MIME_TYPES = [
|
||||
@@ -22,6 +23,11 @@ const ALLOWED_MIME_TYPES = [
|
||||
'video/mp4', 'video/quicktime', 'video/webm'
|
||||
];
|
||||
|
||||
// Types MIME autorisés pour l'audio
|
||||
const ALLOWED_AUDIO_MIME_TYPES = [
|
||||
'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg'
|
||||
];
|
||||
|
||||
// Configuration multer avec validation de taille et type
|
||||
// Configuration multer avec validation de taille (4GB) et stockage disque temporaire
|
||||
const upload = multer({
|
||||
@@ -49,6 +55,29 @@ const upload = multer({
|
||||
}
|
||||
});
|
||||
|
||||
// Setup audio upload with different filters
|
||||
const audioUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: os.tmpdir(),
|
||||
filename: (req, file, cb) => {
|
||||
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 for audio
|
||||
files: 5
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (ALLOWED_AUDIO_MIME_TYPES.includes(file.mimetype) || file.originalname.match(/\.(mp3|wav|ogg)$/i)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error(`Type de fichier audio non autorisé: ${file.mimetype}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Middleware pour vérifier l'authentification
|
||||
function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.isAuthenticated()) {
|
||||
@@ -948,6 +977,100 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Audio Tracks Management (Admin Only)
|
||||
app.post("/api/audio-tracks", requireAdmin, audioUpload.single("file"), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: "No audio file uploaded" });
|
||||
}
|
||||
|
||||
console.log(`🎵 Processing audio upload: ${req.file.originalname} (${(req.file.size / 1024 / 1024).toFixed(2)} MB)`);
|
||||
|
||||
const user = req.user as User;
|
||||
|
||||
// Upload to Cloudinary using file path (streamed from disk) as resource_type: "video" (audio is categorized as video in cloudinary)
|
||||
const uploadResult = await cloudinaryService.uploadMedia(
|
||||
req.file.path,
|
||||
req.file.originalname,
|
||||
user.id,
|
||||
"video"
|
||||
);
|
||||
|
||||
// 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 audio file:", err));
|
||||
|
||||
const title = req.body.title || req.file.originalname.replace(/\.[^/.]+$/, ""); // Use provided title or fallback to filename w/o ext
|
||||
|
||||
const track = await storage.createAudioTrack({
|
||||
userId: user.id,
|
||||
title: title,
|
||||
fileName: req.file.originalname,
|
||||
url: uploadResult.originalUrl,
|
||||
duration: 0, // We could extract duration, but 0 is default
|
||||
});
|
||||
|
||||
res.json(track);
|
||||
} catch (error) {
|
||||
console.error("Error uploading audio track:", 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 audio track";
|
||||
res.status(500).json({ error: errorMessage });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/audio-tracks", requireAuth, async (req, res) => {
|
||||
try {
|
||||
// Both admin and regular users can list audio tracks (to pick them for reels)
|
||||
const tracks = await storage.getAudioTracks();
|
||||
res.json(tracks);
|
||||
} catch (error) {
|
||||
console.error("Error fetching audio tracks:", error);
|
||||
res.status(500).json({ error: "Failed to fetch audio tracks" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/audio-tracks/:id", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const track = await storage.getAudioTrack(id);
|
||||
if (!track) {
|
||||
return res.status(404).json({ error: "Audio track not found" });
|
||||
}
|
||||
|
||||
// Try to extract publicId from cloudinary url to delete it from there too
|
||||
// e.g. https://res.cloudinary.com/demo/video/upload/v123456789/user_id/audio_name.mp3
|
||||
const urlParts = track.url.split('/upload/');
|
||||
if (urlParts.length > 1) {
|
||||
const afterUpload = urlParts[1];
|
||||
// Remove version number (v12345...)
|
||||
let pathWithoutVersion = afterUpload;
|
||||
if (afterUpload.match(/^v\d+\//)) {
|
||||
pathWithoutVersion = afterUpload.replace(/^v\d+\//, '');
|
||||
}
|
||||
// Remove extension
|
||||
const publicId = pathWithoutVersion.replace(/\.[^/.]+$/, "");
|
||||
|
||||
try {
|
||||
await cloudinaryService.deleteMedia(publicId, track.userId || '', 'video');
|
||||
} catch (cloudinaryError) {
|
||||
console.warn(`Failed to delete audio from cloudinary: ${publicId}`, cloudinaryError);
|
||||
}
|
||||
}
|
||||
|
||||
await storage.deleteAudioTrack(id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting audio track:", error);
|
||||
res.status(500).json({ error: "Failed to delete audio track" });
|
||||
}
|
||||
});
|
||||
|
||||
// Posts
|
||||
app.get("/api/posts", requireAuth, async (req, res) => {
|
||||
try {
|
||||
|
||||
+52
-6
@@ -10,6 +10,9 @@ import { ffmpegService } from '../services/ffmpeg';
|
||||
import { facebookService } from '../services/facebook';
|
||||
import { cloudinaryService } from '../services/cloudinary';
|
||||
import { openRouterService } from '../services/openrouter';
|
||||
import { db } from '../db';
|
||||
import { cloudinaryConfig } from '@shared/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const reelsRouter = Router();
|
||||
|
||||
@@ -314,9 +317,25 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
|
||||
// Récupérer l'URL de la musique si trackId fourni
|
||||
let finalMusicUrl = musicUrl;
|
||||
if (musicTrackId && !musicUrl) {
|
||||
const track = await freeSoundService.getMusicDetails(musicTrackId);
|
||||
if (track) {
|
||||
finalMusicUrl = track.downloadUrl;
|
||||
if (musicTrackId.startsWith('internal_')) {
|
||||
const internalId = musicTrackId.replace('internal_', '');
|
||||
const track = await storage.getAudioTrack(internalId);
|
||||
if (track) {
|
||||
finalMusicUrl = track.url;
|
||||
}
|
||||
} else {
|
||||
const track = await freeSoundService.getMusicDetails(musicTrackId);
|
||||
if (track) {
|
||||
finalMusicUrl = track.downloadUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let watermarkUrl: string | undefined = undefined;
|
||||
if (req.user) {
|
||||
const [userCloudinary] = await db.select().from(cloudinaryConfig).where(eq(cloudinaryConfig.userId, req.user.id));
|
||||
if (userCloudinary && userCloudinary.logoPublicId) {
|
||||
watermarkUrl = `https://res.cloudinary.com/${userCloudinary.cloudName}/image/upload/${userCloudinary.logoPublicId}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +350,7 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
|
||||
musicVolume,
|
||||
drawText,
|
||||
stabilize,
|
||||
watermarkUrl,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
@@ -483,9 +503,23 @@ async function processReelBackground(
|
||||
// 2. Récupérer l'URL de la musique
|
||||
let finalMusicUrl = musicUrl;
|
||||
if (musicTrackId && !musicUrl) {
|
||||
const track = await freeSoundService.getMusicDetails(musicTrackId);
|
||||
if (track) {
|
||||
finalMusicUrl = track.downloadUrl;
|
||||
if (musicTrackId.startsWith('internal_')) {
|
||||
try {
|
||||
const internalId = musicTrackId.replace('internal_', '');
|
||||
const track = await storage.getAudioTrack(internalId);
|
||||
if (track) {
|
||||
finalMusicUrl = track.url;
|
||||
}
|
||||
} catch (e) { console.error('Error fetching internal track', e); }
|
||||
} else {
|
||||
try {
|
||||
const track = await freeSoundService.getMusicDetails(musicTrackId);
|
||||
if (track) {
|
||||
finalMusicUrl = track.downloadUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`⚠️ [Background] Failed to fetch music details for ${musicTrackId}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,6 +533,17 @@ async function processReelBackground(
|
||||
// 3. Traiter la vidéo via FFmpeg
|
||||
await updateProgress(15);
|
||||
const startTime = Date.now();
|
||||
|
||||
let watermarkUrl: string | undefined = undefined;
|
||||
try {
|
||||
const [userCloudinary] = await db.select().from(cloudinaryConfig).where(eq(cloudinaryConfig.userId, media.userId));
|
||||
if (userCloudinary && userCloudinary.logoPublicId) {
|
||||
watermarkUrl = `https://res.cloudinary.com/${userCloudinary.cloudName}/image/upload/${userCloudinary.logoPublicId}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching watermark configuration', e);
|
||||
}
|
||||
|
||||
const ffmpegResult = await ffmpegService.processReelFromUrl(media.originalUrl, {
|
||||
text: overlayText,
|
||||
musicUrl: finalMusicUrl,
|
||||
@@ -509,6 +554,7 @@ async function processReelBackground(
|
||||
musicVolume,
|
||||
drawText,
|
||||
stabilize,
|
||||
watermarkUrl,
|
||||
});
|
||||
|
||||
console.log(`⏱️ [Background] FFmpeg took ${(Date.now() - startTime) / 1000}s`);
|
||||
|
||||
@@ -18,6 +18,7 @@ interface FFmpegReelRequest {
|
||||
music_volume?: number; // Volume musique (default: 0.25)
|
||||
draw_text?: boolean; // Dessiner le texte sur la vidéo (default: true)
|
||||
stabilize?: boolean; // Stabilisation vidéo via vidstab (default: false)
|
||||
watermark_url?: string; // URL du logo
|
||||
}
|
||||
|
||||
interface FFmpegReelResponse {
|
||||
@@ -73,6 +74,7 @@ export class FFmpegService {
|
||||
musicVolume?: number;
|
||||
drawText?: boolean;
|
||||
stabilize?: boolean;
|
||||
watermarkUrl?: string;
|
||||
} = {}
|
||||
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
|
||||
const config = this.ensureConfigured();
|
||||
@@ -89,6 +91,7 @@ export class FFmpegService {
|
||||
music_volume: options.musicVolume ?? 0.25,
|
||||
draw_text: options.drawText ?? true,
|
||||
stabilize: options.stabilize ?? false,
|
||||
watermark_url: options.watermarkUrl,
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
@@ -168,6 +171,7 @@ export class FFmpegService {
|
||||
musicVolume?: number;
|
||||
drawText?: boolean;
|
||||
stabilize?: boolean;
|
||||
watermarkUrl?: string;
|
||||
} = {}
|
||||
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
|
||||
const config = this.ensureConfigured();
|
||||
@@ -184,6 +188,7 @@ export class FFmpegService {
|
||||
music_volume: options.musicVolume ?? 0.25,
|
||||
draw_text: options.drawText ?? true,
|
||||
stabilize: options.stabilize ?? false,
|
||||
watermark_url: options.watermarkUrl,
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
|
||||
@@ -34,6 +34,9 @@ import {
|
||||
freesoundConfig,
|
||||
type FreesoundConfig,
|
||||
type InsertFreesoundConfig,
|
||||
audioTracks,
|
||||
type AudioTrack,
|
||||
type InsertAudioTrack,
|
||||
} from "@shared/schema";
|
||||
import { db } from "./db";
|
||||
import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm";
|
||||
@@ -114,6 +117,12 @@ export interface IStorage {
|
||||
removeMusicFavorite(userId: string, trackId: string): Promise<void>;
|
||||
isMusicFavorite(userId: string, trackId: string): Promise<boolean>;
|
||||
|
||||
// Audio Tracks
|
||||
getAudioTracks(): Promise<AudioTrack[]>;
|
||||
getAudioTrack(id: string): Promise<AudioTrack | undefined>;
|
||||
createAudioTrack(track: InsertAudioTrack): Promise<AudioTrack>;
|
||||
deleteAudioTrack(id: string): Promise<void>;
|
||||
|
||||
// Freesound Config
|
||||
getFreesoundConfig(userId: string): Promise<FreesoundConfig | undefined>;
|
||||
createFreesoundConfig(config: InsertFreesoundConfig): Promise<FreesoundConfig>;
|
||||
@@ -541,6 +550,25 @@ export class DatabaseStorage implements IStorage {
|
||||
return !!fav;
|
||||
}
|
||||
|
||||
// Audio Tracks
|
||||
async getAudioTracks(): Promise<AudioTrack[]> {
|
||||
return db.select().from(audioTracks).orderBy(desc(audioTracks.createdAt));
|
||||
}
|
||||
|
||||
async getAudioTrack(id: string): Promise<AudioTrack | undefined> {
|
||||
const [track] = await db.select().from(audioTracks).where(eq(audioTracks.id, id));
|
||||
return track || undefined;
|
||||
}
|
||||
|
||||
async createAudioTrack(track: InsertAudioTrack): Promise<AudioTrack> {
|
||||
const [newTrack] = await db.insert(audioTracks).values(track).returning();
|
||||
return newTrack;
|
||||
}
|
||||
|
||||
async deleteAudioTrack(id: string): Promise<void> {
|
||||
await db.delete(audioTracks).where(eq(audioTracks.id, id));
|
||||
}
|
||||
|
||||
// Freesound Config
|
||||
async getFreesoundConfig(userId: string): Promise<FreesoundConfig | undefined> {
|
||||
const [config] = await db.select().from(freesoundConfig).where(eq(freesoundConfig.userId, userId));
|
||||
|
||||
Reference in new issue
Block a user