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