mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-22 22:48:09 +02:00
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:
1 parent
7b39b34afb
commit
3d494d4056
10 files changed
+2069
-60
No files matched your search
+247
-28
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user