mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-21 22:18:12 +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")
|
() => import("@/pages/mobile/sql")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const AudioAdmin = createResponsiveRoute(
|
||||||
|
() => import("@/pages/audio-admin"),
|
||||||
|
() => import("@/pages/mobile/audio-admin")
|
||||||
|
);
|
||||||
|
|
||||||
const UsersAdmin = createResponsiveRoute(
|
const UsersAdmin = createResponsiveRoute(
|
||||||
() => import("@/pages/users-admin"),
|
() => import("@/pages/users-admin"),
|
||||||
() => import("@/pages/mobile/users-admin")
|
() => import("@/pages/mobile/users-admin")
|
||||||
@@ -134,6 +139,7 @@ function Router() {
|
|||||||
<Route path="/history">{() => <ProtectedRoute component={History} />}</Route>
|
<Route path="/history">{() => <ProtectedRoute component={History} />}</Route>
|
||||||
<Route path="/settings">{() => <ProtectedRoute component={Settings} adminOnly />}</Route>
|
<Route path="/settings">{() => <ProtectedRoute component={Settings} adminOnly />}</Route>
|
||||||
<Route path="/sql">{() => <ProtectedRoute component={SqlAdmin} 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="/users">{() => <ProtectedRoute component={UsersAdmin} adminOnly />}</Route>
|
||||||
<Route path="/analytics">{() => <ProtectedRoute component={Analytics} />}</Route>
|
<Route path="/analytics">{() => <ProtectedRoute component={Analytics} />}</Route>
|
||||||
<Route path="/test-camera" component={TestCamera} />
|
<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 { Link, useLocation } from "wouter";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
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
|
// État pour stocker les musiques chargées manuellement
|
||||||
const [loadedTracks, setLoadedTracks] = useState<MusicTrack[]>([]);
|
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
|
// Recherche de musiques par durée
|
||||||
const { data: musicData, isLoading: musicLoading, refetch: refetchMusic } = useQuery<{ tracks: MusicTrack[] }>({
|
const { data: musicData, isLoading: musicLoading, refetch: refetchMusic } = useQuery<{ tracks: MusicTrack[] }>({
|
||||||
queryKey: ['/api/music/search', selectedVideo?.id, musicOffset],
|
queryKey: ['/api/music/search', selectedVideo?.id, musicOffset],
|
||||||
@@ -539,6 +557,54 @@ export default function NewReel() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
|
<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 ? (
|
{musicLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ class ReelRequest(BaseModel):
|
|||||||
text: Optional[str] = None
|
text: Optional[str] = None
|
||||||
music_id: Optional[str] = None
|
music_id: Optional[str] = None
|
||||||
music_url: Optional[str] = None
|
music_url: Optional[str] = None
|
||||||
|
watermark_url: Optional[str] = None
|
||||||
word_duration: float = 0.6
|
word_duration: float = 0.6
|
||||||
font_size: int = 64
|
font_size: int = 64
|
||||||
music_volume: float = 0.25
|
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}")
|
print(f"Failed to download music: {e}")
|
||||||
# We continue without music if it fails
|
# 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
|
stats["download_duration"] = time.time() - start_step
|
||||||
start_step = time.time()
|
start_step = time.time()
|
||||||
|
|
||||||
@@ -729,6 +743,12 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
|
|||||||
tts_idx = input_count
|
tts_idx = input_count
|
||||||
input_count += 1
|
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 ---
|
# --- Filter Complex Construction ---
|
||||||
fc_parts = []
|
fc_parts = []
|
||||||
|
|
||||||
@@ -772,6 +792,10 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
|
|||||||
# Combine formatting + text
|
# Combine formatting + text
|
||||||
v_chain += text_filter
|
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
|
# End of video chain
|
||||||
v_chain += "[vout]"
|
v_chain += "[vout]"
|
||||||
fc_parts.append(v_chain)
|
fc_parts.append(v_chain)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { User, InsertUser, ScheduledPost, FreesoundConfig } from "@shared/s
|
|||||||
import { freeSoundService } from "./services/freesound";
|
import { freeSoundService } from "./services/freesound";
|
||||||
import { analyticsRouter } from "./routes/analytics";
|
import { analyticsRouter } from "./routes/analytics";
|
||||||
import { reelsRouter } from "./routes/reels";
|
import { reelsRouter } from "./routes/reels";
|
||||||
|
import { insertAudioTrackSchema } from "@shared/schema";
|
||||||
|
|
||||||
// Types MIME autorisés pour les uploads
|
// Types MIME autorisés pour les uploads
|
||||||
const ALLOWED_MIME_TYPES = [
|
const ALLOWED_MIME_TYPES = [
|
||||||
@@ -22,6 +23,11 @@ const ALLOWED_MIME_TYPES = [
|
|||||||
'video/mp4', 'video/quicktime', 'video/webm'
|
'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 et type
|
||||||
// Configuration multer avec validation de taille (4GB) et stockage disque temporaire
|
// Configuration multer avec validation de taille (4GB) et stockage disque temporaire
|
||||||
const upload = multer({
|
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
|
// Middleware pour vérifier l'authentification
|
||||||
function requireAuth(req: Request, res: Response, next: NextFunction) {
|
function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
if (!req.isAuthenticated()) {
|
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
|
// Posts
|
||||||
app.get("/api/posts", requireAuth, async (req, res) => {
|
app.get("/api/posts", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+52
-6
@@ -10,6 +10,9 @@ import { ffmpegService } from '../services/ffmpeg';
|
|||||||
import { facebookService } from '../services/facebook';
|
import { facebookService } from '../services/facebook';
|
||||||
import { cloudinaryService } from '../services/cloudinary';
|
import { cloudinaryService } from '../services/cloudinary';
|
||||||
import { openRouterService } from '../services/openrouter';
|
import { openRouterService } from '../services/openrouter';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { cloudinaryConfig } from '@shared/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
export const reelsRouter = Router();
|
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
|
// Récupérer l'URL de la musique si trackId fourni
|
||||||
let finalMusicUrl = musicUrl;
|
let finalMusicUrl = musicUrl;
|
||||||
if (musicTrackId && !musicUrl) {
|
if (musicTrackId && !musicUrl) {
|
||||||
const track = await freeSoundService.getMusicDetails(musicTrackId);
|
if (musicTrackId.startsWith('internal_')) {
|
||||||
if (track) {
|
const internalId = musicTrackId.replace('internal_', '');
|
||||||
finalMusicUrl = track.downloadUrl;
|
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,
|
musicVolume,
|
||||||
drawText,
|
drawText,
|
||||||
stabilize,
|
stabilize,
|
||||||
|
watermarkUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -483,9 +503,23 @@ async function processReelBackground(
|
|||||||
// 2. Récupérer l'URL de la musique
|
// 2. Récupérer l'URL de la musique
|
||||||
let finalMusicUrl = musicUrl;
|
let finalMusicUrl = musicUrl;
|
||||||
if (musicTrackId && !musicUrl) {
|
if (musicTrackId && !musicUrl) {
|
||||||
const track = await freeSoundService.getMusicDetails(musicTrackId);
|
if (musicTrackId.startsWith('internal_')) {
|
||||||
if (track) {
|
try {
|
||||||
finalMusicUrl = track.downloadUrl;
|
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
|
// 3. Traiter la vidéo via FFmpeg
|
||||||
await updateProgress(15);
|
await updateProgress(15);
|
||||||
const startTime = Date.now();
|
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, {
|
const ffmpegResult = await ffmpegService.processReelFromUrl(media.originalUrl, {
|
||||||
text: overlayText,
|
text: overlayText,
|
||||||
musicUrl: finalMusicUrl,
|
musicUrl: finalMusicUrl,
|
||||||
@@ -509,6 +554,7 @@ async function processReelBackground(
|
|||||||
musicVolume,
|
musicVolume,
|
||||||
drawText,
|
drawText,
|
||||||
stabilize,
|
stabilize,
|
||||||
|
watermarkUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`⏱️ [Background] FFmpeg took ${(Date.now() - startTime) / 1000}s`);
|
console.log(`⏱️ [Background] FFmpeg took ${(Date.now() - startTime) / 1000}s`);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface FFmpegReelRequest {
|
|||||||
music_volume?: number; // Volume musique (default: 0.25)
|
music_volume?: number; // Volume musique (default: 0.25)
|
||||||
draw_text?: boolean; // Dessiner le texte sur la vidéo (default: true)
|
draw_text?: boolean; // Dessiner le texte sur la vidéo (default: true)
|
||||||
stabilize?: boolean; // Stabilisation vidéo via vidstab (default: false)
|
stabilize?: boolean; // Stabilisation vidéo via vidstab (default: false)
|
||||||
|
watermark_url?: string; // URL du logo
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FFmpegReelResponse {
|
interface FFmpegReelResponse {
|
||||||
@@ -73,6 +74,7 @@ export class FFmpegService {
|
|||||||
musicVolume?: number;
|
musicVolume?: number;
|
||||||
drawText?: boolean;
|
drawText?: boolean;
|
||||||
stabilize?: boolean;
|
stabilize?: boolean;
|
||||||
|
watermarkUrl?: string;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
|
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
|
||||||
const config = this.ensureConfigured();
|
const config = this.ensureConfigured();
|
||||||
@@ -89,6 +91,7 @@ export class FFmpegService {
|
|||||||
music_volume: options.musicVolume ?? 0.25,
|
music_volume: options.musicVolume ?? 0.25,
|
||||||
draw_text: options.drawText ?? true,
|
draw_text: options.drawText ?? true,
|
||||||
stabilize: options.stabilize ?? false,
|
stabilize: options.stabilize ?? false,
|
||||||
|
watermark_url: options.watermarkUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove undefined values
|
// Remove undefined values
|
||||||
@@ -168,6 +171,7 @@ export class FFmpegService {
|
|||||||
musicVolume?: number;
|
musicVolume?: number;
|
||||||
drawText?: boolean;
|
drawText?: boolean;
|
||||||
stabilize?: boolean;
|
stabilize?: boolean;
|
||||||
|
watermarkUrl?: string;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
|
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
|
||||||
const config = this.ensureConfigured();
|
const config = this.ensureConfigured();
|
||||||
@@ -184,6 +188,7 @@ export class FFmpegService {
|
|||||||
music_volume: options.musicVolume ?? 0.25,
|
music_volume: options.musicVolume ?? 0.25,
|
||||||
draw_text: options.drawText ?? true,
|
draw_text: options.drawText ?? true,
|
||||||
stabilize: options.stabilize ?? false,
|
stabilize: options.stabilize ?? false,
|
||||||
|
watermark_url: options.watermarkUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove undefined values
|
// Remove undefined values
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ import {
|
|||||||
freesoundConfig,
|
freesoundConfig,
|
||||||
type FreesoundConfig,
|
type FreesoundConfig,
|
||||||
type InsertFreesoundConfig,
|
type InsertFreesoundConfig,
|
||||||
|
audioTracks,
|
||||||
|
type AudioTrack,
|
||||||
|
type InsertAudioTrack,
|
||||||
} from "@shared/schema";
|
} from "@shared/schema";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm";
|
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>;
|
removeMusicFavorite(userId: string, trackId: string): Promise<void>;
|
||||||
isMusicFavorite(userId: string, trackId: string): Promise<boolean>;
|
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
|
// Freesound Config
|
||||||
getFreesoundConfig(userId: string): Promise<FreesoundConfig | undefined>;
|
getFreesoundConfig(userId: string): Promise<FreesoundConfig | undefined>;
|
||||||
createFreesoundConfig(config: InsertFreesoundConfig): Promise<FreesoundConfig>;
|
createFreesoundConfig(config: InsertFreesoundConfig): Promise<FreesoundConfig>;
|
||||||
@@ -541,6 +550,25 @@ export class DatabaseStorage implements IStorage {
|
|||||||
return !!fav;
|
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
|
// Freesound Config
|
||||||
async getFreesoundConfig(userId: string): Promise<FreesoundConfig | undefined> {
|
async getFreesoundConfig(userId: string): Promise<FreesoundConfig | undefined> {
|
||||||
const [config] = await db.select().from(freesoundConfig).where(eq(freesoundConfig.userId, userId));
|
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(),
|
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", {
|
export const userPagePermissions = pgTable("user_page_permissions", {
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
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
|
// Insert schemas
|
||||||
export const insertUserSchema = createInsertSchema(users).pick({
|
export const insertUserSchema = createInsertSchema(users).pick({
|
||||||
username: true,
|
username: true,
|
||||||
@@ -407,6 +424,11 @@ export const insertMusicFavoriteSchema = createInsertSchema(musicFavorites).omit
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const insertAudioTrackSchema = createInsertSchema(audioTracks).omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
export type InsertUser = z.infer<typeof insertUserSchema>;
|
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 InsertOpenrouterConfig = z.infer<typeof insertOpenrouterConfigSchema>;
|
||||||
export type UpdateOpenrouterConfig = z.infer<typeof updateOpenrouterConfigSchema>;
|
export type UpdateOpenrouterConfig = z.infer<typeof updateOpenrouterConfigSchema>;
|
||||||
|
|
||||||
|
export const insertPostMediaSchema = createInsertSchema(postMedia).omit({
|
||||||
|
id: true,
|
||||||
|
});
|
||||||
|
|
||||||
export type PostMedia = typeof postMedia.$inferSelect;
|
export type PostMedia = typeof postMedia.$inferSelect;
|
||||||
export type InsertPostMedia = z.infer<typeof insertPostMediaSchema>;
|
export type InsertPostMedia = z.infer<typeof insertPostMediaSchema>;
|
||||||
|
|
||||||
export type MusicFavorite = typeof musicFavorites.$inferSelect;
|
export type MusicFavorite = typeof musicFavorites.$inferSelect;
|
||||||
export type InsertMusicFavorite = z.infer<typeof insertMusicFavoriteSchema>;
|
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({
|
export const insertFreesoundConfigSchema = createInsertSchema(freesoundConfig).omit({
|
||||||
id: true,
|
id: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Ajout d'une fonctionnalité complète de création de Reels Facebook permettant
|
|||||||
|
|
||||||
## Current Focus
|
## 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
|
## 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] **Entry Point**: Verify no forced redirects to `/new`.
|
||||||
- [x] **Hub Design**: Refactor `DashboardMobile` with `Card` components and consistent styling.
|
- [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 ✅
|
### Phase 13: Activation Agent BMad ✅
|
||||||
|
|
||||||
- [x] Activer l'agent `bmad-master.md`
|
- [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
|
## 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 `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] Analyze `server/routes.ts` and `server/storage.ts` for deletion logic <!-- id: 6 -->
|
||||||
- [x] Create `implementation_plan.md` <!-- id: 7 -->
|
- [x] Create `implementation_plan.md` <!-- id: 7 -->
|
||||||
|
|||||||
Reference in new issue
Block a user