feat: Add Facebook Reels with music and text overlay - Add ffmpeg.ts service for Docker FFmpeg API integration - Add jamendo.ts service for royalty-free music search - Add publishReel methods to facebook.ts - Add reels API routes (music search, AI text generation, create/preview) - Add new-reel.tsx frontend with 4-step workflow - Add 'reel' type to postTypeEnum - Update navigation with Nouveau Reel link

This commit is contained in:
Michael committed 2026-01-22 11:57:27 +01:00
1 parent 7b39b34afb
commit 3d494d4056
10 files changed
+2069 -60

No files matched your search

+4
View File
@@ -11,6 +11,7 @@ import { cloudinaryService } from "./services/cloudinary";
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
import type { User, InsertUser, ScheduledPost } from "@shared/schema";
import { analyticsRouter } from "./routes/analytics";
import { reelsRouter } from "./routes/reels";
// Types MIME autorisés pour les uploads
const ALLOWED_MIME_TYPES = [
@@ -59,6 +60,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Analytics Routes
app.use("/api/analytics", requireAuth, analyticsRouter);
// Reels & Music Routes
app.use("/api", requireAuth, reelsRouter);
// Routes d'authentification
app.post("/api/auth/login", (req, res, next) => {
passport.authenticate("local", (err: any, user: User | false, info: any) => {
+404
View File
@@ -0,0 +1,404 @@
/**
* Routes API pour les Reels Facebook et la musique Jamendo
*/
import { Router, Request, Response } from 'express';
import type { User, Media, SocialPage } from '@shared/schema';
import { storage } from '../storage';
import { jamendoService, type MusicTrack } from '../services/jamendo';
import { ffmpegService } from '../services/ffmpeg';
import { facebookService } from '../services/facebook';
import { cloudinaryService } from '../services/cloudinary';
import { openRouterService } from '../services/openrouter';
export const reelsRouter = Router();
// ============================================
// ROUTES MUSIQUE JAMENDO
// ============================================
/**
* Recherche de musiques par durée
* GET /api/music/search?minDuration=10&maxDuration=60&genre=pop&limit=10
*/
reelsRouter.get('/music/search', async (req: Request, res: Response) => {
try {
const {
minDuration = '10',
maxDuration = '120',
genre,
limit = '10',
offset = '0',
search,
} = req.query;
const tracks = await jamendoService.searchMusicByDuration({
minDuration: parseInt(minDuration as string),
maxDuration: parseInt(maxDuration as string),
genre: genre as string | undefined,
limit: parseInt(limit as string),
offset: parseInt(offset as string),
search: search as string | undefined,
});
res.json({
tracks,
pagination: {
offset: parseInt(offset as string),
limit: parseInt(limit as string),
hasMore: tracks.length === parseInt(limit as string),
},
});
} catch (error) {
console.error('❌ Error searching music:', error);
res.status(500).json({ error: 'Erreur lors de la recherche de musique' });
}
});
/**
* Voir plus de musiques (pagination)
* GET /api/music/more?offset=10&minDuration=10&maxDuration=60
*/
reelsRouter.get('/music/more', async (req: Request, res: Response) => {
try {
const {
minDuration = '10',
maxDuration = '120',
genre,
limit = '10',
offset = '0',
search,
} = req.query;
const tracks = await jamendoService.searchMusicByDuration({
minDuration: parseInt(minDuration as string),
maxDuration: parseInt(maxDuration as string),
genre: genre as string | undefined,
limit: parseInt(limit as string),
offset: parseInt(offset as string),
search: search as string | undefined,
});
res.json({
tracks,
pagination: {
offset: parseInt(offset as string),
limit: parseInt(limit as string),
hasMore: tracks.length === parseInt(limit as string),
},
});
} catch (error) {
console.error('❌ Error loading more music:', error);
res.status(500).json({ error: 'Erreur lors du chargement des musiques' });
}
});
/**
* Obtenir les tracks populaires
* GET /api/music/popular
*/
reelsRouter.get('/music/popular', async (req: Request, res: Response) => {
try {
const limit = parseInt(req.query.limit as string) || 10;
const tracks = await jamendoService.getPopularTracks(limit);
res.json({ tracks });
} catch (error) {
console.error('❌ Error fetching popular music:', error);
res.status(500).json({ error: 'Erreur lors de la récupération des musiques populaires' });
}
});
/**
* Détails d'un track
* GET /api/music/:trackId
*/
reelsRouter.get('/music/:trackId', async (req: Request, res: Response) => {
try {
const { trackId } = req.params;
const track = await jamendoService.getMusicDetails(trackId);
if (!track) {
return res.status(404).json({ error: 'Musique non trouvée' });
}
res.json(track);
} catch (error) {
console.error('❌ Error fetching music details:', error);
res.status(500).json({ error: 'Erreur lors de la récupération des détails' });
}
});
// ============================================
// ROUTES REELS
// ============================================
/**
* Générer des textes IA pour un Reel (utilise le même service que les posts)
* POST /api/reels/generate-text
*/
reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const { productInfo, model } = req.body;
if (!productInfo) {
return res.status(400).json({ error: 'Informations produit requises' });
}
const generatedTexts = await openRouterService.generatePostText(productInfo, user.id, model);
// Sauvegarder la génération
await storage.createAiGeneration({
userId: user.id,
productInfo,
generatedTexts,
});
res.json({ variants: generatedTexts });
} catch (error) {
console.error('❌ Error generating Reel text:', error);
res.status(500).json({ error: 'Erreur lors de la génération du texte' });
}
});
/**
* Prévisualiser un Reel (traitement sans publication)
* POST /api/reels/preview
*/
reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const {
videoMediaId,
musicTrackId,
musicUrl,
overlayText,
wordDuration = 0.6,
fontSize = 60,
musicVolume = 0.25,
} = req.body;
// Récupérer le média vidéo
const media = await storage.getMediaById(videoMediaId);
if (!media) {
return res.status(404).json({ error: 'Vidéo non trouvée' });
}
if (media.type !== 'video') {
return res.status(400).json({ error: 'Le média doit être une vidéo' });
}
// Récupérer l'URL de la musique si trackId fourni
let finalMusicUrl = musicUrl;
if (musicTrackId && !musicUrl) {
const track = await jamendoService.getMusicDetails(musicTrackId);
if (track) {
finalMusicUrl = track.downloadUrl;
}
}
// Traiter la vidéo via FFmpeg
const result = await ffmpegService.processReelFromUrl(media.originalUrl, {
text: overlayText,
musicUrl: finalMusicUrl,
wordDuration,
fontSize,
musicVolume,
});
if (!result.success) {
return res.status(500).json({ error: result.error || 'Erreur de traitement vidéo' });
}
// Retourner la vidéo en base64 pour prévisualisation
res.json({
success: true,
videoBase64: result.videoBase64,
duration: result.duration,
});
} catch (error) {
console.error('❌ Error previewing Reel:', error);
res.status(500).json({ error: 'Erreur lors de la prévisualisation du Reel' });
}
});
/**
* Créer et publier un Reel
* POST /api/reels
*/
reelsRouter.post('/reels', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const {
videoMediaId,
musicTrackId,
musicUrl,
overlayText,
description,
pageIds,
scheduledFor,
wordDuration = 0.6,
fontSize = 60,
musicVolume = 0.25,
} = req.body;
// Validation
if (!videoMediaId) {
return res.status(400).json({ error: 'Vidéo requise' });
}
if (!pageIds || pageIds.length === 0) {
return res.status(400).json({ error: 'Au moins une page requise' });
}
// Récupérer le média vidéo
const media = await storage.getMediaById(videoMediaId);
if (!media) {
return res.status(404).json({ error: 'Vidéo non trouvée' });
}
if (media.type !== 'video') {
return res.status(400).json({ error: 'Le média doit être une vidéo' });
}
// Récupérer l'URL de la musique si trackId fourni
let finalMusicUrl = musicUrl;
if (musicTrackId && !musicUrl) {
const track = await jamendoService.getMusicDetails(musicTrackId);
if (track) {
finalMusicUrl = track.downloadUrl;
}
}
console.log('🎬 Processing Reel:', {
videoMediaId,
hasMusic: !!finalMusicUrl,
hasText: !!overlayText,
pageCount: pageIds.length,
});
// Traiter la vidéo via FFmpeg
const ffmpegResult = await ffmpegService.processReelFromUrl(media.originalUrl, {
text: overlayText,
musicUrl: finalMusicUrl,
wordDuration,
fontSize,
musicVolume,
});
if (!ffmpegResult.success || !ffmpegResult.videoBase64) {
return res.status(500).json({
error: ffmpegResult.error || 'Erreur de traitement vidéo',
});
}
// Upload la vidéo traitée sur Cloudinary
const videoBuffer = Buffer.from(ffmpegResult.videoBase64, 'base64');
const cloudinaryResult = await cloudinaryService.uploadMedia(
videoBuffer,
`reel-${Date.now()}.mp4`,
user.id,
'video/mp4'
);
console.log('✅ Reel uploaded to Cloudinary:', cloudinaryResult.originalUrl);
// Créer le post en base
const post = await storage.createPost({
userId: user.id,
content: description || overlayText || '',
aiGenerated: 'false',
status: scheduledFor ? 'scheduled' : 'draft',
scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
});
// Publier sur chaque page sélectionnée
const results: { pageId: string; success: boolean; reelId?: string; error?: string }[] = [];
for (const pageId of pageIds) {
try {
const page = await storage.getSocialPage(pageId);
if (!page) {
results.push({ pageId, success: false, error: 'Page non trouvée' });
continue;
}
if (page.platform !== 'facebook') {
results.push({ pageId, success: false, error: 'Seules les pages Facebook sont supportées pour les Reels' });
continue;
}
// Créer l'entrée scheduled_post
const scheduledPost = await storage.createScheduledPost({
postId: post.id,
pageId: page.id,
postType: 'reel',
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
});
// Si pas de planification, publier immédiatement
if (!scheduledFor) {
const reelId = await facebookService.publishReel(
page,
cloudinaryResult.originalUrl,
description
);
// Mettre à jour le scheduled_post avec l'ID externe
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId: reelId,
});
results.push({ pageId, success: true, reelId });
} else {
results.push({ pageId, success: true, reelId: 'scheduled' });
}
} catch (pageError) {
console.error(`❌ Error publishing Reel to page ${pageId}:`, pageError);
results.push({
pageId,
success: false,
error: pageError instanceof Error ? pageError.message : 'Erreur inconnue',
});
}
}
// Mettre à jour le statut du post
const allSuccess = results.every(r => r.success);
await storage.updatePost(post.id, {
status: scheduledFor ? 'scheduled' : (allSuccess ? 'published' : 'failed'),
});
res.json({
success: allSuccess,
postId: post.id,
results,
videoUrl: cloudinaryResult.originalUrl,
});
} catch (error) {
console.error('❌ Error creating Reel:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Erreur lors de la création du Reel',
});
}
});
/**
* Obtenir la configuration des services Reels
* GET /api/reels/config
*/
reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
try {
const jamendoConfigured = await jamendoService.testConnection().catch(() => false);
const ffmpegConfigured = await ffmpegService.healthCheck().catch(() => false);
res.json({
jamendo: { configured: jamendoConfigured },
ffmpeg: { configured: ffmpegConfigured },
});
} catch (error) {
console.error('❌ Error fetching Reels config:', error);
res.status(500).json({ error: 'Erreur lors de la récupération de la configuration' });
}
});
+247 -28
View File
@@ -43,27 +43,27 @@ export class FacebookService {
if (postType === 'both') {
throw new Error('postType "both" is not supported. Posts should be split into separate feed and story entries at creation.');
}
if (postType === 'story') {
// Publish as story - one story per media
if (mediaList.length === 0) {
throw new Error('Stories require media (photo or video)');
}
// Publish each media as a separate story
const storyIds: string[] = [];
for (const media of mediaList) {
const storyId = await this.publishStory(post, page, media);
storyIds.push(storyId);
}
// Return all story IDs joined together
return storyIds.join(',');
} else {
// Default to feed
const imageMedia = mediaList.filter(m => m.type === 'image');
const videoMedia = mediaList.filter(m => m.type === 'video');
if (imageMedia.length > 1) {
// Multi-photo carousel (images only)
return await this.publishMultiPhotoPost(post, page, mediaList);
@@ -135,7 +135,7 @@ export class FacebookService {
// Check if this is a Cloudinary URL
const cloudinaryPattern = /https:\/\/res\.cloudinary\.com\/([^\/]+)\/video\/upload\//;
const match = originalUrl.match(cloudinaryPattern);
if (!match) {
// Not a Cloudinary URL, return as-is (may fail on Facebook)
console.warn('Video URL is not from Cloudinary, cannot apply transformations:', originalUrl);
@@ -144,20 +144,20 @@ export class FacebookService {
const [fullMatch, cloudName] = match;
const videoPath = originalUrl.replace(fullMatch, '');
// Apply transformations for Facebook compatibility:
// - vc_h264: Video codec H.264
// - ac_aac: Audio codec AAC
// - f_mp4: Force MP4 format
// - q_auto: Automatic quality optimization
const transformations = 'vc_h264,ac_aac,f_mp4,q_auto';
const transformedUrl = `https://res.cloudinary.com/${cloudName}/video/upload/${transformations}/${videoPath}`;
console.log('🎬 Transforming video for Facebook compatibility:');
console.log(' Original:', originalUrl);
console.log(' Transformed:', transformedUrl);
return transformedUrl;
}
@@ -193,7 +193,7 @@ export class FacebookService {
if (imageMedia.length === 0) {
throw new Error('Multi-photo posts require at least one image');
}
if (imageMedia.length !== mediaList.length) {
console.warn(`Filtered out ${mediaList.length - imageMedia.length} non-image media from multi-photo post`);
}
@@ -204,7 +204,7 @@ export class FacebookService {
for (const media of imageMedia) {
// Use original URL - Facebook handles the cropping for carousel
const photoUrl = media.originalUrl;
if (!photoUrl) {
console.warn(`Skipping media ${media.id} - no valid URL found`);
continue;
@@ -230,7 +230,7 @@ export class FacebookService {
const uploadData = await uploadResponse.json() as FacebookPhotoResponse;
photoIds.push(uploadData.id);
}
if (photoIds.length === 0) {
throw new Error('No photos were successfully uploaded');
}
@@ -347,7 +347,7 @@ export class FacebookService {
if (media.type === 'video') {
return this.publishVideoStory(post, page, media);
}
// Stories require a 2-step process:
// Step 1: Upload photo as unpublished to get photo_id
// Step 2: Publish the photo as a story using the photo_id
@@ -372,7 +372,7 @@ export class FacebookService {
});
const uploadUrl = `${this.baseUrl}/${page.pageId}/photos?${uploadParams.toString()}`;
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
});
@@ -411,31 +411,31 @@ export class FacebookService {
// 1. START phase - Initialize the upload and get upload_url
// 2. UPLOAD phase - Upload the video to upload_url using file_url header
// 3. FINISH phase - Finalize and publish the story
const videoUrl = media.originalUrl;
const accessToken = page.accessToken!;
// Phase 1: START - Initialize the upload
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
const startUrl = `${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`;
const startResponse = await fetch(startUrl, {
method: 'POST',
});
if (!startResponse.ok) {
const error = await startResponse.json() as FacebookError;
throw new Error(`Facebook API error (START phase): ${error.error.message} (code: ${error.error.code})`);
}
const startData = await startResponse.json() as { video_id: string; upload_url: string };
const videoId = startData.video_id;
const uploadUrl = startData.upload_url;
// Phase 2: UPLOAD - Upload the video using the upload_url
// IMPORTANT: file_url must be passed as a header, not a query parameter
const uploadResponse = await fetch(uploadUrl, {
@@ -445,35 +445,254 @@ export class FacebookService {
'file_url': videoUrl,
},
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(`Facebook API error (UPLOAD phase): ${errorText}`);
}
// Phase 3: FINISH - Finalize and publish the story
const finishParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'finish',
video_id: videoId,
});
const finishUrl = `${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`;
const finishResponse = await fetch(finishUrl, {
method: 'POST',
});
if (!finishResponse.ok) {
const error = await finishResponse.json() as FacebookError;
throw new Error(`Facebook API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
}
const finishData = await finishResponse.json() as { success: boolean; post_id?: string };
// Return the post_id from the finish response
return finishData.post_id || videoId;
}
/**
* Publie une vidéo comme Reel Facebook
*
* Utilise l'endpoint /{page-id}/video_reels avec un processus en 3 phases :
* 1. START - Initialise l'upload et récupère l'upload_url
* 2. UPLOAD - Upload la vidéo vers rupload.facebook.com
* 3. FINISH - Finalise et publie le Reel
*
* Spécifications vidéo requises :
* - Format : MP4, H.264
* - Ratio : 9:16 (vertical)
* - Durée : 3-90 secondes
* - Résolution recommandée : 1080x1920
*
* @param page - Page Facebook cible
* @param videoUrl - URL de la vidéo à publier
* @param description - Description du Reel (optionnel)
* @returns ID du Reel publié
*/
async publishReel(
page: SocialPage,
videoUrl: string,
description?: string
): Promise<string> {
if (page.platform !== 'facebook') {
throw new Error('This method only supports Facebook pages');
}
if (!page.accessToken) {
throw new Error('No access token found for this page');
}
const accessToken = page.accessToken;
console.log('🎬 Publishing Reel to Facebook:', {
pageId: page.pageId,
pageName: page.pageName,
hasDescription: !!description,
});
// Phase 1: START - Initialize the upload
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
const startUrl = `${this.baseUrl}/${page.pageId}/video_reels?${startParams.toString()}`;
console.log('📤 Phase 1: START - Initializing Reel upload');
const startResponse = await fetch(startUrl, {
method: 'POST',
});
if (!startResponse.ok) {
const error = await startResponse.json() as FacebookError;
throw new Error(`Facebook Reel API error (START phase): ${error.error.message} (code: ${error.error.code})`);
}
const startData = await startResponse.json() as { video_id: string; upload_url: string };
const videoId = startData.video_id;
const uploadUrl = startData.upload_url;
console.log('✅ START phase complete, video_id:', videoId);
// Phase 2: UPLOAD - Upload the video to rupload.facebook.com
// IMPORTANT: file_url must be passed as a header for URL-based uploads
console.log('📤 Phase 2: UPLOAD - Uploading video to:', uploadUrl);
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `OAuth ${accessToken}`,
'file_url': videoUrl,
},
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
console.error('❌ UPLOAD phase failed:', errorText);
throw new Error(`Facebook Reel API error (UPLOAD phase): ${errorText}`);
}
console.log('✅ UPLOAD phase complete');
// Phase 3: FINISH - Finalize and publish the Reel
const finishParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'finish',
video_id: videoId,
video_state: 'PUBLISHED',
});
// Add description if provided
if (description) {
finishParams.append('description', description);
}
const finishUrl = `${this.baseUrl}/${page.pageId}/video_reels?${finishParams.toString()}`;
console.log('📤 Phase 3: FINISH - Publishing Reel');
const finishResponse = await fetch(finishUrl, {
method: 'POST',
});
if (!finishResponse.ok) {
const error = await finishResponse.json() as FacebookError;
throw new Error(`Facebook Reel API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
}
const finishData = await finishResponse.json() as { success: boolean; post_id?: string };
if (!finishData.success) {
throw new Error('Facebook Reel publishing failed - success was false');
}
const reelId = finishData.post_id || videoId;
console.log('✅ Reel published successfully! ID:', reelId);
return reelId;
}
/**
* Publie une vidéo comme Reel Facebook en uploadant directement les données binaires
* Utile quand on a la vidéo en base64 (après traitement FFmpeg)
*
* @param page - Page Facebook cible
* @param videoBuffer - Buffer de la vidéo
* @param description - Description du Reel (optionnel)
* @returns ID du Reel publié
*/
async publishReelFromBuffer(
page: SocialPage,
videoBuffer: ArrayBuffer,
description?: string
): Promise<string> {
if (page.platform !== 'facebook') {
throw new Error('This method only supports Facebook pages');
}
if (!page.accessToken) {
throw new Error('No access token found for this page');
}
const accessToken = page.accessToken;
const fileSize = videoBuffer.byteLength;
console.log('🎬 Publishing Reel from buffer:', {
pageId: page.pageId,
fileSize: `${(fileSize / 1024 / 1024).toFixed(2)} MB`,
});
// Phase 1: START
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
const startUrl = `${this.baseUrl}/${page.pageId}/video_reels?${startParams.toString()}`;
const startResponse = await fetch(startUrl, {
method: 'POST',
});
if (!startResponse.ok) {
const error = await startResponse.json() as FacebookError;
throw new Error(`Facebook Reel API error (START phase): ${error.error.message} (code: ${error.error.code})`);
}
const startData = await startResponse.json() as { video_id: string; upload_url: string };
const videoId = startData.video_id;
const uploadUrl = startData.upload_url;
// Phase 2: UPLOAD - Upload binary data directly
const videoBlob = new Blob([videoBuffer], { type: 'video/mp4' });
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `OAuth ${accessToken}`,
'Content-Type': 'application/octet-stream',
'offset': '0',
'file_size': fileSize.toString(),
},
body: videoBlob,
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(`Facebook Reel API error (UPLOAD phase): ${errorText}`);
}
// Phase 3: FINISH
const finishParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'finish',
video_id: videoId,
video_state: 'PUBLISHED',
});
if (description) {
finishParams.append('description', description);
}
const finishUrl = `${this.baseUrl}/${page.pageId}/video_reels?${finishParams.toString()}`;
const finishResponse = await fetch(finishUrl, {
method: 'POST',
});
if (!finishResponse.ok) {
const error = await finishResponse.json() as FacebookError;
throw new Error(`Facebook Reel API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
}
const finishData = await finishResponse.json() as { success: boolean; post_id?: string };
return finishData.post_id || videoId;
}
}
export const facebookService = new FacebookService();
+245
View File
@@ -0,0 +1,245 @@
/**
* FFmpeg Docker API Service
*
* Intégration avec l'API FFmpeg Docker locale pour le traitement vidéo des Reels.
* L'API attend une vidéo en base64 et retourne la vidéo traitée en base64.
*/
interface FFmpegReelRequest {
video_base64?: string; // Vidéo source en base64
video_url?: string; // OU URL de la vidéo source
text?: string; // Texte overlay style TikTok
music_id?: string; // ID de la musique (catalogue FFmpeg)
music_url?: string; // OU URL directe de la musique
word_duration?: number; // Durée par mot (default: 0.6s)
font_size?: number; // Taille police (default: 60)
music_volume?: number; // Volume musique (default: 0.25)
}
interface FFmpegReelResponse {
success: boolean;
output_base64?: string; // Vidéo traitée en base64
duration?: number; // Durée de la vidéo en secondes
detail?: string; // Message d'erreur si échec
}
interface FFmpegConfig {
apiUrl: string;
apiKey: string;
}
export class FFmpegService {
private config: FFmpegConfig | null = null;
/**
* Configure le service avec l'URL et la clé API
*/
configure(apiUrl: string, apiKey: string): void {
this.config = { apiUrl, apiKey };
console.log('🎬 FFmpeg Service configured:', apiUrl);
}
/**
* Vérifie que le service est configuré
*/
private ensureConfigured(): FFmpegConfig {
if (!this.config) {
throw new Error('FFmpeg Service not configured. Call configure() first.');
}
return this.config;
}
/**
* Traite une vidéo pour créer un Reel avec musique et texte overlay
*
* @param videoBase64 - Vidéo source encodée en base64
* @param options - Options de traitement (texte, musique, etc.)
* @returns Vidéo traitée en base64
*/
async processReelVideo(
videoBase64: string,
options: {
text?: string;
musicId?: string;
musicUrl?: string;
wordDuration?: number;
fontSize?: number;
musicVolume?: number;
} = {}
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
const config = this.ensureConfigured();
const requestBody: FFmpegReelRequest = {
video_base64: videoBase64,
text: options.text,
music_id: options.musicId,
music_url: options.musicUrl,
word_duration: options.wordDuration ?? 0.6,
font_size: options.fontSize ?? 60,
music_volume: options.musicVolume ?? 0.25,
};
// Remove undefined values
Object.keys(requestBody).forEach(key => {
if (requestBody[key as keyof FFmpegReelRequest] === undefined) {
delete requestBody[key as keyof FFmpegReelRequest];
}
});
console.log('🎬 Processing Reel video:', {
hasVideo: !!videoBase64,
hasText: !!options.text,
hasMusicId: !!options.musicId,
hasMusicUrl: !!options.musicUrl,
});
try {
const response = await fetch(`${config.apiUrl}/process-reel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': config.apiKey,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ FFmpeg API error:', response.status, errorText);
return {
success: false,
error: `FFmpeg API error: ${response.status} - ${errorText}`,
};
}
const data = await response.json() as FFmpegReelResponse;
if (!data.success) {
console.error('❌ FFmpeg processing failed:', data.detail);
return {
success: false,
error: data.detail || 'Unknown FFmpeg processing error',
};
}
console.log('✅ Reel video processed successfully, duration:', data.duration);
return {
success: true,
videoBase64: data.output_base64,
duration: data.duration,
};
} catch (error) {
console.error('❌ FFmpeg Service error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Traite une vidéo depuis une URL (télécharge, traite, retourne base64)
*/
async processReelFromUrl(
videoUrl: string,
options: {
text?: string;
musicId?: string;
musicUrl?: string;
wordDuration?: number;
fontSize?: number;
musicVolume?: number;
} = {}
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
const config = this.ensureConfigured();
const requestBody: FFmpegReelRequest = {
video_url: videoUrl,
text: options.text,
music_id: options.musicId,
music_url: options.musicUrl,
word_duration: options.wordDuration ?? 0.6,
font_size: options.fontSize ?? 60,
music_volume: options.musicVolume ?? 0.25,
};
// Remove undefined values
Object.keys(requestBody).forEach(key => {
if (requestBody[key as keyof FFmpegReelRequest] === undefined) {
delete requestBody[key as keyof FFmpegReelRequest];
}
});
console.log('🎬 Processing Reel from URL:', {
videoUrl,
hasText: !!options.text,
hasMusicId: !!options.musicId,
hasMusicUrl: !!options.musicUrl,
});
try {
const response = await fetch(`${config.apiUrl}/process-reel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': config.apiKey,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ FFmpeg API error:', response.status, errorText);
return {
success: false,
error: `FFmpeg API error: ${response.status} - ${errorText}`,
};
}
const data = await response.json() as FFmpegReelResponse;
if (!data.success) {
console.error('❌ FFmpeg processing failed:', data.detail);
return {
success: false,
error: data.detail || 'Unknown FFmpeg processing error',
};
}
console.log('✅ Reel video processed successfully from URL');
return {
success: true,
videoBase64: data.output_base64,
duration: data.duration,
};
} catch (error) {
console.error('❌ FFmpeg Service error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Vérifie la santé de l'API FFmpeg
*/
async healthCheck(): Promise<boolean> {
try {
const config = this.ensureConfigured();
const response = await fetch(`${config.apiUrl}/health`, {
method: 'GET',
headers: {
'X-API-Key': config.apiKey,
},
});
return response.ok;
} catch {
return false;
}
}
}
export const ffmpegService = new FFmpegService();
+296
View File
@@ -0,0 +1,296 @@
/**
* Jamendo Music API Service
*
* Intégration avec l'API Jamendo pour rechercher et récupérer des musiques
* libres de droits pour les Reels.
*
* Documentation: https://developer.jamendo.com/v3.0
*/
interface JamendoTrack {
id: string;
name: string;
duration: number; // en secondes
artist_id: string;
artist_name: string;
album_name: string;
album_id: string;
license_ccurl: string;
position: number;
releasedate: string;
album_image: string;
audio: string; // URL du fichier audio complet
audiodownload: string; // URL de téléchargement
prourl: string;
shorturl: string;
shareurl: string;
waveform: string;
image: string;
audiodownload_allowed: boolean;
}
interface JamendoResponse {
headers: {
status: string;
code: number;
error_message: string;
warnings: string;
results_count: number;
};
results: JamendoTrack[];
}
export interface MusicTrack {
id: string;
title: string;
artist: string;
albumName: string;
duration: number; // en secondes
previewUrl: string; // URL audio streaming
downloadUrl: string; // URL téléchargement
imageUrl: string; // Pochette album
license: string;
}
interface MusicSearchParams {
minDuration?: number; // Durée min en secondes
maxDuration?: number; // Durée max en secondes
genre?: string; // Genre musical (ex: "pop", "rock", "electronic")
mood?: string; // Ambiance (ex: "happy", "sad", "energetic")
limit?: number; // Nombre de résultats (default: 10)
offset?: number; // Offset pour pagination
search?: string; // Recherche texte libre
}
interface JamendoConfig {
clientId: string;
}
const JAMENDO_API_BASE = 'https://api.jamendo.com/v3.0';
// Mapping des genres Jamendo
const GENRE_MAPPING: Record<string, string> = {
'pop': 'pop',
'rock': 'rock',
'electronic': 'electronic',
'hiphop': 'hiphop',
'jazz': 'jazz',
'classical': 'classical',
'ambient': 'ambient',
'chill': 'chillout',
'dance': 'dance',
'indie': 'indie',
};
export class JamendoService {
private config: JamendoConfig | null = null;
/**
* Configure le service avec le Client ID Jamendo
*/
configure(clientId: string): void {
this.config = { clientId };
console.log('🎵 Jamendo Service configured');
}
/**
* Vérifie que le service est configuré
*/
private ensureConfigured(): JamendoConfig {
if (!this.config) {
throw new Error('Jamendo Service not configured. Call configure() first.');
}
return this.config;
}
/**
* Convertit un track Jamendo en format MusicTrack unifié
*/
private mapTrack(track: JamendoTrack): MusicTrack {
return {
id: track.id,
title: track.name,
artist: track.artist_name,
albumName: track.album_name,
duration: track.duration,
previewUrl: track.audio,
downloadUrl: track.audiodownload,
imageUrl: track.album_image || track.image,
license: track.license_ccurl,
};
}
/**
* Recherche des musiques par durée
*
* @param params - Paramètres de recherche
* @returns Liste de tracks correspondants
*/
async searchMusicByDuration(params: MusicSearchParams = {}): Promise<MusicTrack[]> {
const config = this.ensureConfigured();
const {
minDuration = 10,
maxDuration = 120,
genre,
limit = 10,
offset = 0,
search,
} = params;
// Construction de l'URL avec les paramètres
const queryParams = new URLSearchParams({
client_id: config.clientId,
format: 'json',
limit: limit.toString(),
offset: offset.toString(),
include: 'musicinfo',
audioformat: 'mp32', // MP3 320kbps
durationbetween: `${minDuration}_${maxDuration}`,
order: 'popularity_total',
});
// Ajouter le genre si spécifié
if (genre && GENRE_MAPPING[genre.toLowerCase()]) {
queryParams.append('tags', GENRE_MAPPING[genre.toLowerCase()]);
} else if (genre) {
queryParams.append('tags', genre.toLowerCase());
}
// Ajouter la recherche texte si spécifiée
if (search) {
queryParams.append('namesearch', search);
}
const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`;
console.log('🎵 Searching Jamendo:', {
duration: `${minDuration}-${maxDuration}s`,
genre,
limit,
offset,
});
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Jamendo API error: ${response.status}`);
}
const data = await response.json() as JamendoResponse;
if (data.headers.code !== 0) {
throw new Error(`Jamendo API error: ${data.headers.error_message}`);
}
const tracks = data.results.map(track => this.mapTrack(track));
console.log(`✅ Found ${tracks.length} tracks on Jamendo`);
return tracks;
} catch (error) {
console.error('❌ Jamendo search error:', error);
throw error;
}
}
/**
* Récupère les détails d'un track par son ID
*/
async getMusicDetails(trackId: string): Promise<MusicTrack | null> {
const config = this.ensureConfigured();
const queryParams = new URLSearchParams({
client_id: config.clientId,
format: 'json',
id: trackId,
include: 'musicinfo',
audioformat: 'mp32',
});
const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Jamendo API error: ${response.status}`);
}
const data = await response.json() as JamendoResponse;
if (data.headers.code !== 0 || data.results.length === 0) {
return null;
}
return this.mapTrack(data.results[0]);
} catch (error) {
console.error('❌ Jamendo getMusicDetails error:', error);
return null;
}
}
/**
* Récupère des tracks populaires (utilisé comme fallback ou découverte)
*/
async getPopularTracks(limit: number = 10): Promise<MusicTrack[]> {
const config = this.ensureConfigured();
const queryParams = new URLSearchParams({
client_id: config.clientId,
format: 'json',
limit: limit.toString(),
include: 'musicinfo',
audioformat: 'mp32',
order: 'popularity_week',
durationbetween: '15_90', // Durée idéale pour Reels
});
const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Jamendo API error: ${response.status}`);
}
const data = await response.json() as JamendoResponse;
if (data.headers.code !== 0) {
throw new Error(`Jamendo API error: ${data.headers.error_message}`);
}
return data.results.map(track => this.mapTrack(track));
} catch (error) {
console.error('❌ Jamendo getPopularTracks error:', error);
return [];
}
}
/**
* Télécharge l'URL du fichier audio (avec redirection si nécessaire)
* Note: Jamendo fournit directement les URLs de téléchargement
*/
getDownloadUrl(track: MusicTrack): string {
// L'URL de téléchargement Jamendo est directement utilisable
return track.downloadUrl;
}
/**
* Vérifie la validité de la configuration
*/
async testConnection(): Promise<boolean> {
try {
const tracks = await this.getPopularTracks(1);
return tracks.length > 0;
} catch {
return false;
}
}
}
export const jamendoService = new JamendoService();