feat: Implement internal MP3 management and logo overlay functionality for Reels.

This commit is contained in:
Michael committed 2026-03-02 11:33:22 +01:00
1 parent d89de20df2
commit 0e9622b5eb
12 files changed
+632 -8

No files matched your search

+6
View File
@@ -60,6 +60,11 @@ const SqlAdmin = createResponsiveRoute(
() => import("@/pages/mobile/sql")
);
const AudioAdmin = createResponsiveRoute(
() => import("@/pages/audio-admin"),
() => import("@/pages/mobile/audio-admin")
);
const UsersAdmin = createResponsiveRoute(
() => import("@/pages/users-admin"),
() => import("@/pages/mobile/users-admin")
@@ -134,6 +139,7 @@ function Router() {
<Route path="/history">{() => <ProtectedRoute component={History} />}</Route>
<Route path="/settings">{() => <ProtectedRoute component={Settings} adminOnly />}</Route>
<Route path="/sql">{() => <ProtectedRoute component={SqlAdmin} adminOnly />}</Route>
<Route path="/audio">{() => <ProtectedRoute component={AudioAdmin} adminOnly />}</Route>
<Route path="/users">{() => <ProtectedRoute component={UsersAdmin} adminOnly />}</Route>
<Route path="/analytics">{() => <ProtectedRoute component={Analytics} />}</Route>
<Route path="/test-camera" component={TestCamera} />
+1 -1
View File
@@ -1,4 +1,4 @@
import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, Wand2, BarChart3, Video } from "lucide-react";
import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, Wand2, BarChart3, Video, Music } from "lucide-react";
import { Link, useLocation } from "wouter";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
+275
View File
@@ -0,0 +1,275 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { Trash2, Music, Upload, Loader2, Play, Pause } from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
interface AudioTrack {
id: number;
userId: number;
title: string;
url: string;
duration: number | null;
createdAt: string;
}
export default function AudioAdmin() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [playingTrackId, setPlayingTrackId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: tracks = [], isLoading } = useQuery<AudioTrack[]>({
queryKey: ["/api/audio-tracks"],
});
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append("audio", file);
const res = await fetch("/api/audio-tracks", {
method: "POST",
body: formData,
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || "Erreur lors de l'upload");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/audio-tracks"] });
toast({
title: "Succès",
description: "La piste audio a été ajoutée à la bibliothèque",
});
},
onError: (error: Error) => {
toast({
title: "Erreur",
description: error.message,
variant: "destructive",
});
},
onSettled: () => {
setIsUploading(false);
}
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/api/audio-tracks/${id}`, {
method: "DELETE",
});
if (!res.ok) {
throw new Error("Erreur lors de la suppression");
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/audio-tracks"] });
toast({
title: "Succès",
description: "La piste audio a été supprimée",
});
},
onError: (error: Error) => {
toast({
title: "Erreur",
description: error.message,
variant: "destructive",
});
}
});
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("audio/")) {
toast({
title: "Format invalide",
description: "Veuillez sélectionner un fichier audio (MP3, WAV, etc.)",
variant: "destructive",
});
return;
}
setIsUploading(true);
uploadMutation.mutate(file);
// Reset input
if (e.target) {
e.target.value = "";
}
};
const togglePlay = (track: AudioTrack) => {
if (playingTrackId === track.id) {
audioElement?.pause();
setPlayingTrackId(null);
} else {
if (audioElement) {
audioElement.pause();
}
const newAudio = new Audio(track.url);
newAudio.play();
newAudio.onended = () => {
setPlayingTrackId(null);
};
setAudioElement(newAudio);
setPlayingTrackId(track.id);
}
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto w-full">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-7xl mx-auto space-y-8">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<Music className="w-8 h-8 text-primary" />
Bibliothèque Audio
</h1>
<p className="text-muted-foreground mt-2">
Gérez les musiques disponibles pour la création de Reels
</p>
</div>
<div>
<input
type="file"
id="audio-upload"
className="hidden"
accept="audio/*"
onChange={handleFileUpload}
disabled={isUploading}
/>
<label htmlFor="audio-upload">
<Button
asChild
disabled={isUploading}
className="cursor-pointer"
>
<span>
{isUploading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{isUploading ? "Upload en cours..." : "Ajouter un fichier audio"}
</span>
</Button>
</label>
</div>
</div>
<Card className="border-border/50 shadow-sm">
<CardHeader>
<CardTitle>Musiques ({tracks.length})</CardTitle>
<CardDescription>
Ces pistes pourront être sélectionnées par les utilisateurs lors de la création d'un nouveau Reel.
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center p-8">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : tracks.length === 0 ? (
<div className="text-center p-12 border-2 border-dashed border-border rounded-xl">
<Music className="w-12 h-12 text-muted-foreground mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-medium mb-1">Aucune musique</h3>
<p className="text-muted-foreground">
Vous n'avez pas encore ajouté de piste audio. Les musiques ajoutées ici seront utilisables pour les Reels.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tracks.map((track) => (
<div
key={track.id}
className="bg-card border border-border/50 rounded-xl p-4 flex flex-col gap-4 hover:border-primary/30 transition-colors shadow-sm"
>
<div className="flex items-start justify-between gap-3">
<div className="p-3 bg-primary/10 rounded-lg shrink-0">
<Music className="w-6 h-6 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm truncate" title={track.title}>{track.title}</p>
<p className="text-xs text-muted-foreground mt-1">
{format(new Date(track.createdAt), "dd MMM yyyy", { locale: fr })}
</p>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-border/50">
<Button
variant="ghost"
size="sm"
className={playingTrackId === track.id ? "text-primary bg-primary/10" : ""}
onClick={() => togglePlay(track)}
>
{playingTrackId === track.id ? (
<><Pause className="w-4 h-4 mr-2" /> Pause</>
) : (
<><Play className="w-4 h-4 mr-2" /> Écouter</>
)}
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (confirm("Voulez-vous vraiment supprimer cette musique ?")) {
deleteMutation.mutate(track.id);
}
}}
disabled={deleteMutation.isPending}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
import AudioAdmin from "../audio-admin";
export default AudioAdmin;
+66
View File
@@ -97,6 +97,24 @@ export default function NewReel() {
// État pour stocker les musiques chargées manuellement
const [loadedTracks, setLoadedTracks] = useState<MusicTrack[]>([]);
// --- Added for Internal Audio Tracks ---
const { data: internalTracksResponse = [], isLoading: internalTracksLoading } = useQuery<any[]>({
queryKey: ['/api/audio-tracks'],
});
const internalTracks: MusicTrack[] = (internalTracksResponse || []).map(t => ({
id: `internal_${t.id}`,
title: t.title,
artist: "Bibliothèque Interne",
albumName: "Upload",
duration: t.duration || 0,
previewUrl: t.url,
downloadUrl: t.url,
imageUrl: "",
license: "Internal"
}));
// --- End Internal Tracks ---
// Recherche de musiques par durée
const { data: musicData, isLoading: musicLoading, refetch: refetchMusic } = useQuery<{ tracks: MusicTrack[] }>({
queryKey: ['/api/music/search', selectedVideo?.id, musicOffset],
@@ -539,6 +557,54 @@ export default function NewReel() {
<CardContent>
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
{/* INTERNAL TRACKS SECTION */}
<div className="mb-6 space-y-2">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider px-1">Bibliothèque Interne</h3>
{internalTracksLoading ? (
<div className="flex justify-center py-4"><Loader2 className="w-6 h-6 animate-spin text-primary" /></div>
) : internalTracks.length === 0 ? (
<p className="text-sm text-muted-foreground italic px-1 bg-muted/30 p-3 rounded-lg">Aucune musique n'a été ajoutée par les administrateurs.</p>
) : (
internalTracks.map((track) => (
<div
key={track.id}
onClick={() => 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'
}`}
>
<button
onClick={(e) => {
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 ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5 ml-0.5" />
)}
</button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.title}</p>
<p className="text-xs text-primary truncate font-medium flex items-center gap-1">
<Music className="w-3 h-3" />
{track.artist}
</p>
</div>
{selectedTrack?.id === track.id && (
<Check className="w-5 h-5 text-primary shrink-0" />
)}
</div>
))
)}
</div>
{/* END INTERNAL TRACKS SECTION */}
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider px-1 mb-2">Recherche FreeSound</h3>
{musicLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
+24
View File
@@ -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)
+123
View File
@@ -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
View File
@@ -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`);
+5
View File
@@ -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
+28
View File
@@ -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));
+29
View File
@@ -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<typeof insertUserSchema>;
@@ -434,12 +456,19 @@ export type OpenrouterConfig = typeof openrouterConfig.$inferSelect;
export type InsertOpenrouterConfig = z.infer<typeof insertOpenrouterConfigSchema>;
export type UpdateOpenrouterConfig = z.infer<typeof updateOpenrouterConfigSchema>;
export const insertPostMediaSchema = createInsertSchema(postMedia).omit({
id: true,
});
export type PostMedia = typeof postMedia.$inferSelect;
export type InsertPostMedia = z.infer<typeof insertPostMediaSchema>;
export type MusicFavorite = typeof musicFavorites.$inferSelect;
export type InsertMusicFavorite = z.infer<typeof insertMusicFavoriteSchema>;
export type AudioTrack = typeof audioTracks.$inferSelect;
export type InsertAudioTrack = z.infer<typeof insertAudioTrackSchema>;
export const insertFreesoundConfigSchema = createInsertSchema(freesoundConfig).omit({
id: true,
createdAt: true,
+21 -1
View File
@@ -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 <!-- id: 5 -->
- [x] Analyze `server/routes.ts` and `server/storage.ts` for deletion logic <!-- id: 6 -->
- [x] Create `implementation_plan.md` <!-- id: 7 -->