+
Bibliothèque Interne
+ {internalTracksLoading ? (
+
+ ) : internalTracks.length === 0 ? (
+
Aucune musique n'a été ajoutée par les administrateurs.
+ ) : (
+ internalTracks.map((track) => (
+
handleSelectTrack(track)}
+ className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all ${selectedTrack?.id === track.id
+ ? 'bg-primary/10 border border-primary'
+ : 'bg-muted/50 hover:bg-muted'
+ }`}
+ >
+
{
+ e.stopPropagation();
+ togglePlayPreview(track);
+ }}
+ className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center hover:bg-primary/30 shrink-0"
+ >
+ {isPlaying === track.id ? (
+
+ ) : (
+
+ )}
+
+
+
{track.title}
+
+
+ {track.artist}
+
+
+ {selectedTrack?.id === track.id && (
+
+ )}
+
+ ))
+ )}
+
+ {/* END INTERNAL TRACKS SECTION */}
+
+
diff --git a/ffmpeg-service/main.py b/ffmpeg-service/main.py
index 2c579a7..1f711c0 100644
--- a/ffmpeg-service/main.py
+++ b/ffmpeg-service/main.py
@@ -152,6 +152,7 @@ class ReelRequest(BaseModel):
text: Optional[str] = None
music_id: Optional[str] = None
music_url: Optional[str] = None
+ watermark_url: Optional[str] = None
word_duration: float = 0.6
font_size: int = 64
music_volume: float = 0.25
@@ -586,6 +587,19 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
print(f"Failed to download music: {e}")
# We continue without music if it fails
+ has_watermark = False
+ if request.watermark_url:
+ try:
+ # Add User-Agent to avoid 403 on some CDNs
+ headers = {"User-Agent": "Mozilla/5.0"}
+ response = requests.get(request.watermark_url, headers=headers, stream=True)
+ response.raise_for_status()
+ with open(job_dir / "watermark.png", "wb") as f:
+ shutil.copyfileobj(response.raw, f)
+ has_watermark = True
+ except Exception as e:
+ print(f"Failed to download watermark: {e}")
+
stats["download_duration"] = time.time() - start_step
start_step = time.time()
@@ -729,6 +743,12 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
tts_idx = input_count
input_count += 1
+ watermark_idx = -1
+ if has_watermark:
+ cmd.extend(["-i", str(job_dir / "watermark.png")])
+ watermark_idx = input_count
+ input_count += 1
+
# --- Filter Complex Construction ---
fc_parts = []
@@ -772,6 +792,10 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
# Combine formatting + text
v_chain += text_filter
+ if has_watermark:
+ fc_parts.append(f"[{watermark_idx}:v]scale=150:-1[wm]")
+ v_chain += "[v_pre_wm];[v_pre_wm][wm]overlay=W-w-20:H-h-20"
+
# End of video chain
v_chain += "[vout]"
fc_parts.append(v_chain)
diff --git a/server/routes.ts b/server/routes.ts
index 315fdb2..eecc4fd 100644
--- a/server/routes.ts
+++ b/server/routes.ts
@@ -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
{
}
});
+ // 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 {
diff --git a/server/routes/reels.ts b/server/routes/reels.ts
index cb31b3a..44e6c6e 100644
--- a/server/routes/reels.ts
+++ b/server/routes/reels.ts
@@ -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`);
diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts
index ad90266..3fae73d 100644
--- a/server/services/ffmpeg.ts
+++ b/server/services/ffmpeg.ts
@@ -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
diff --git a/server/storage.ts b/server/storage.ts
index 9d912a0..b947cfb 100644
--- a/server/storage.ts
+++ b/server/storage.ts
@@ -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;
isMusicFavorite(userId: string, trackId: string): Promise;
+ // Audio Tracks
+ getAudioTracks(): Promise;
+ getAudioTrack(id: string): Promise;
+ createAudioTrack(track: InsertAudioTrack): Promise;
+ deleteAudioTrack(id: string): Promise;
+
// Freesound Config
getFreesoundConfig(userId: string): Promise;
createFreesoundConfig(config: InsertFreesoundConfig): Promise;
@@ -541,6 +550,25 @@ export class DatabaseStorage implements IStorage {
return !!fav;
}
+ // Audio Tracks
+ async getAudioTracks(): Promise {
+ return db.select().from(audioTracks).orderBy(desc(audioTracks.createdAt));
+ }
+
+ async getAudioTrack(id: string): Promise {
+ const [track] = await db.select().from(audioTracks).where(eq(audioTracks.id, id));
+ return track || undefined;
+ }
+
+ async createAudioTrack(track: InsertAudioTrack): Promise {
+ const [newTrack] = await db.insert(audioTracks).values(track).returning();
+ return newTrack;
+ }
+
+ async deleteAudioTrack(id: string): Promise {
+ await db.delete(audioTracks).where(eq(audioTracks.id, id));
+ }
+
// Freesound Config
async getFreesoundConfig(userId: string): Promise {
const [config] = await db.select().from(freesoundConfig).where(eq(freesoundConfig.userId, userId));
diff --git a/shared/schema.ts b/shared/schema.ts
index 8561737..cb029b8 100644
--- a/shared/schema.ts
+++ b/shared/schema.ts
@@ -214,6 +214,16 @@ export const musicFavorites = pgTable("music_favorites", {
createdAt: timestamp("created_at").defaultNow(),
});
+export const audioTracks = pgTable("audio_tracks", {
+ id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
+ userId: varchar("user_id").references(() => users.id, { onDelete: "cascade" }), // Nullable if uploaded by system/admin globally
+ title: text("title").notNull(),
+ fileName: text("file_name").notNull(),
+ url: text("url").notNull(),
+ duration: integer("duration").default(0),
+ createdAt: timestamp("created_at").defaultNow(),
+});
+
export const userPagePermissions = pgTable("user_page_permissions", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
@@ -340,6 +350,13 @@ export const userPagePermissionsRelations = relations(userPagePermissions, ({ on
}),
}));
+export const audioTracksRelations = relations(audioTracks, ({ one }) => ({
+ user: one(users, {
+ fields: [audioTracks.userId],
+ references: [users.id],
+ }),
+}));
+
// Insert schemas
export const insertUserSchema = createInsertSchema(users).pick({
username: true,
@@ -407,6 +424,11 @@ export const insertMusicFavoriteSchema = createInsertSchema(musicFavorites).omit
createdAt: true,
});
+export const insertAudioTrackSchema = createInsertSchema(audioTracks).omit({
+ id: true,
+ createdAt: true,
+});
+
// Types
export type User = typeof users.$inferSelect;
export type InsertUser = z.infer;
@@ -434,12 +456,19 @@ export type OpenrouterConfig = typeof openrouterConfig.$inferSelect;
export type InsertOpenrouterConfig = z.infer;
export type UpdateOpenrouterConfig = z.infer;
+export const insertPostMediaSchema = createInsertSchema(postMedia).omit({
+ id: true,
+});
+
export type PostMedia = typeof postMedia.$inferSelect;
export type InsertPostMedia = z.infer;
export type MusicFavorite = typeof musicFavorites.$inferSelect;
export type InsertMusicFavorite = z.infer;
+export type AudioTrack = typeof audioTracks.$inferSelect;
+export type InsertAudioTrack = z.infer;
+
export const insertFreesoundConfigSchema = createInsertSchema(freesoundConfig).omit({
id: true,
createdAt: true,
diff --git a/task.md b/task.md
index d8a52e9..d99d8a8 100644
--- a/task.md
+++ b/task.md
@@ -6,7 +6,7 @@ Ajout d'une fonctionnalité complète de création de Reels Facebook permettant
## Current Focus
-**Phase: DONE** - Mobile Dashboard Simplification Complete.
+**Phase: EXECUTION** - Implémentation de la gestion interne des musiques (MP3) et incrustation du logo existant sur les Reels.
## Master Plan
@@ -176,6 +176,23 @@ Ajout d'une fonctionnalité complète de création de Reels Facebook permettant
- [x] **Entry Point**: Verify no forced redirects to `/new`.
- [x] **Hub Design**: Refactor `DashboardMobile` with `Card` components and consistent styling.
+### Phase 19: Internal MP3 Management & Logo Overlay 🎵🖼️
+
+- [x] **Database & Routing**
+ - [x] Add `AudioTrack` table to schema (`id`, `title`, `url`, `createdAt`)
+ - [x] Create API routes for managing audio tracks (Upload, List, Delete)
+- [x] **Admin UI**
+ - [x] Create an "Assets" or "Resources" admin page
+ - [x] Implement MP3 upload functionality
+ - [x] Display list of uploaded audio tracks with delete option
+- [x] **Reel Creation UI**
+ - [x] Replace FreeSound search with a selector/list of internal MP3s
+ - [x] Maintain audio preview functionality
+- [ ] **FFmpeg Integration**
+ - [ ] Pass the appropriate `audio_url` (local/hosted MP3) and `logo_url` (from existing settings/image editor) to the FFmpeg service
+ - [ ] Update `ffmpeg-service/main.py` to download/read the logo image
+ - [ ] Implement `overlay` filter in the video processing pipeline to scale (max width 150px) and position the logo in the bottom right corner (e.g., `W-w-20:H-h-20`)
+
### Phase 13: Activation Agent BMad ✅
- [x] Activer l'agent `bmad-master.md`
@@ -185,6 +202,9 @@ Ajout d'une fonctionnalité complète de création de Reels Facebook permettant
## Progress Log
+- [x] Activation du mode Party et salutation de l'utilisateur. En attente du sujet de discussion.
+- [x] Définition des spécifications pour la gestion interne des MP3 et l'incrustation du logo de l'Éditeur d'image. Fin du Mode Party.
+
- [x] Analyze `ffmpeg-service/main.py` for text encoding issues
- [x] Analyze `server/routes.ts` and `server/storage.ts` for deletion logic
- [x] Create `implementation_plan.md`