mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-21 14:08:10 +02:00
feat(reels): montage Remotion unifié, aperçu en direct, tests et CI
Montage (étape 3) : - Nouvelle composition ReelVideo : sous-titres animés mot à mot (3 styles : Impact, Surligné, Épuré), logo, effet de fin et fondu, en React - Le service Python prépare l'image (recadrage, HDR, stabilisation, dernière image figée) et la piste son finale (/prepare-reel) ; Remotion compose - Reels d'images sur les mêmes composants, avec le vrai minutage de la voix - Police Montserrat embarquée (plus de dépendance à Google Fonts) - REEL_RENDERER=ffmpeg conserve le rendu FFmpeg, plus rapide, en secours - Script de pré-bundle réparé (échouait en silence : require en ESM) Interface (étape 4) : - Aperçu en direct avec @remotion/player, identique au rendu final ; la voix testée cale les sous-titres, sinon minutage estimé - Choix du style de sous-titres sur les 4 pages Reel - Vraie progression : étape réelle du rendu, échecs visibles 24 h avec leur cause ; fin de la barre simulée et du faux « publié avec succès » Outillage (étape 5) : - Tests vitest (minutage identique à Python, validation, sécurité) et pytest ; CI GitHub Actions (tsc, tests, build, ruff) - Captures d'écran, out.mp4 et scripts de test retirés de la racine Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
This commit is contained in:
64 files changed
+2488
-782
No files matched your search
@@ -4,16 +4,20 @@ import { Progress } from "@/components/ui/progress";
|
||||
import { Clapperboard, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
import type { Post } from "@shared/schema";
|
||||
|
||||
/** Labels for each progress stage */
|
||||
function getStageLabel(progress: number, status?: string): string {
|
||||
if (status === 'pending') return "En attente de traitement...";
|
||||
if (progress <= 5) return "Démarrage du traitement…";
|
||||
if (progress <= 15) return "Traitement vidéo (FFmpeg)…";
|
||||
if (progress <= 65) return "Encodage en cours…";
|
||||
if (progress <= 85) return "Upload vers le cloud…";
|
||||
if (progress <= 90) return "Liaison du média…";
|
||||
if (progress <= 95) return "Publication en cours…";
|
||||
return "Finalisation…";
|
||||
type OngoingReel = Post & { generationStep?: string | null };
|
||||
|
||||
/** Libellé de l'étape en cours, telle que rapportée par la file de rendu. */
|
||||
const STEP_LABELS: Record<string, string> = {
|
||||
prepare: "Préparation…",
|
||||
voice: "Voix et préparation de la vidéo…",
|
||||
render: "Montage : sous-titres, logo, effets…",
|
||||
store: "Enregistrement dans la médiathèque…",
|
||||
publish: "Publication…",
|
||||
};
|
||||
|
||||
function getStageLabel(post: OngoingReel): string {
|
||||
if (post.generationStatus === "pending") return "En attente : un autre Reel est en cours…";
|
||||
return (post.generationStep && STEP_LABELS[post.generationStep]) || "Démarrage du traitement…";
|
||||
}
|
||||
|
||||
interface OngoingReelsProps {
|
||||
@@ -22,12 +26,13 @@ interface OngoingReelsProps {
|
||||
}
|
||||
|
||||
export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
const { data: ongoingPosts = [] } = useQuery<Post[]>({
|
||||
const { data: ongoingPosts = [] } = useQuery<OngoingReel[]>({
|
||||
queryKey: ["/api/reels/ongoing"],
|
||||
// Cette route ne renvoie que les générations en cours : tant qu'elle est
|
||||
// vide il n'y a pas de barre de progression à animer. On garde une veille
|
||||
// lente, qui suffit à détecter une génération lancée depuis un autre écran.
|
||||
refetchInterval: (query) => ((query.state.data?.length ?? 0) > 0 ? 3000 : 30000),
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some((p) => p.generationStatus !== "failed") ? 3000 : 30000,
|
||||
});
|
||||
|
||||
// Nothing to show — render nothing (not even a card)
|
||||
@@ -43,8 +48,12 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
>
|
||||
<CardHeader className={compact ? "pb-2 px-4 pt-4" : ""}>
|
||||
<CardTitle className={`flex items-center gap-2 ${compact ? "text-base" : ""}`}>
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
Génération en cours
|
||||
{ongoingPosts.some((p) => p.generationStatus !== "failed") ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-destructive" />
|
||||
)}
|
||||
Reels en cours
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className={compact ? "px-4 pb-4 pt-0" : ""}>
|
||||
@@ -73,8 +82,8 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
{isFailed ? (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-destructive">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="text-xs">
|
||||
{post.generationError || "Erreur lors du traitement"}
|
||||
<span className="text-xs break-words">
|
||||
Échec : {post.generationError || "erreur lors du traitement"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -87,7 +96,7 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getStageLabel(progress)}
|
||||
{getStageLabel(post)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{progress}%
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { CAPTION_STYLE_OPTIONS, type CaptionStyle } from "@shared/captions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Aperçu miniature de chaque style de sous-titres. */
|
||||
const SAMPLE: Record<CaptionStyle, React.ReactNode> = {
|
||||
bold: (
|
||||
<span className="font-black uppercase tracking-tight" style={{ WebkitTextStroke: "1px #000", paintOrder: "stroke fill" }}>
|
||||
<span className="text-white">Nos </span>
|
||||
<span className="text-yellow-300">promos</span>
|
||||
</span>
|
||||
),
|
||||
neon: (
|
||||
<span className="font-extrabold">
|
||||
<span className="rounded px-1 bg-black/50 text-white">Nos</span>{" "}
|
||||
<span className="rounded px-1 bg-violet-600 text-white">promos</span>
|
||||
</span>
|
||||
),
|
||||
minimal: (
|
||||
<span className="font-bold">
|
||||
<span className="text-white">Nos </span>
|
||||
<span className="text-white/45">promos</span>
|
||||
</span>
|
||||
),
|
||||
};
|
||||
|
||||
export function CaptionStylePicker({
|
||||
value,
|
||||
onChange,
|
||||
compact = false,
|
||||
}: {
|
||||
value: CaptionStyle;
|
||||
onChange: (style: CaptionStyle) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2" role="radiogroup" aria-label="Style des sous-titres">
|
||||
{CAPTION_STYLE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === option.id}
|
||||
title={option.description}
|
||||
onClick={() => onChange(option.id)}
|
||||
className={cn(
|
||||
"rounded-lg border p-2 text-left transition-colors",
|
||||
value === option.id ? "border-primary ring-2 ring-primary/30" : "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md bg-gradient-to-br from-slate-700 to-slate-900",
|
||||
compact ? "h-9 text-[11px]" : "h-12 text-sm",
|
||||
)}
|
||||
>
|
||||
{SAMPLE[option.id]}
|
||||
</div>
|
||||
<p className={cn("mt-1 font-medium", compact ? "text-[11px]" : "text-xs")}>{option.label}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Player } from "@remotion/player";
|
||||
import { Loader2, Info } from "lucide-react";
|
||||
import {
|
||||
REEL_FPS,
|
||||
REEL_HEIGHT,
|
||||
REEL_WIDTH,
|
||||
VOICE_DELAY,
|
||||
cleanCaptionText,
|
||||
computeImagesTiming,
|
||||
computeReelTiming,
|
||||
offsetWords,
|
||||
spreadWords,
|
||||
type CaptionStyle,
|
||||
type TimedWord,
|
||||
} from "@shared/captions";
|
||||
import { ReelVideo, type ReelVideoProps } from "@/remotion/ReelVideo";
|
||||
import { ImageComposition, type ImageCompositionProps } from "@/remotion/ImageComposition";
|
||||
import type { VoicePreviewResult } from "./voice-picker";
|
||||
|
||||
/** Débit moyen d'une voix de synthèse française (estimation avant génération). */
|
||||
const WORDS_PER_SECOND = 2.6;
|
||||
|
||||
interface CommonProps {
|
||||
text: string;
|
||||
showCaptions: boolean;
|
||||
captionStyle: CaptionStyle;
|
||||
ttsEnabled: boolean;
|
||||
/** Voix générée pour ce texte et ces réglages, sinon null (minutage estimé). */
|
||||
voice: VoicePreviewResult | null;
|
||||
musicUrl?: string;
|
||||
musicVolume: number;
|
||||
logoUrl?: string | null;
|
||||
storeName?: string;
|
||||
endingEffect: boolean;
|
||||
}
|
||||
|
||||
type ReelPreviewProps =
|
||||
| (CommonProps & { kind: "video"; videoUrl: string })
|
||||
| (CommonProps & { kind: "images"; images: string[] });
|
||||
|
||||
/** Durée d'un média lue dans ses métadonnées (vidéo de la médiathèque). */
|
||||
function useVideoDuration(url: string | undefined): number | null {
|
||||
const [duration, setDuration] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
setDuration(null);
|
||||
if (!url) return;
|
||||
const video = document.createElement("video");
|
||||
video.preload = "metadata";
|
||||
video.onloadedmetadata = () => setDuration(Number.isFinite(video.duration) ? video.duration : 15);
|
||||
video.onerror = () => setDuration(15);
|
||||
video.src = url;
|
||||
return () => {
|
||||
video.onloadedmetadata = null;
|
||||
video.onerror = null;
|
||||
video.src = "";
|
||||
};
|
||||
}, [url]);
|
||||
return duration;
|
||||
}
|
||||
|
||||
function estimatedVoiceDuration(text: string): number {
|
||||
const words = cleanCaptionText(text).split(" ").filter(Boolean).length;
|
||||
return words ? words / WORDS_PER_SECOND + 0.4 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aperçu du Reel dans le navigateur, avec les mêmes compositions Remotion que
|
||||
* le rendu final : sous-titres, logo, effet de fin et durée identiques.
|
||||
* Sans voix générée, le minutage des mots est estimé.
|
||||
*/
|
||||
export function ReelPreview(props: ReelPreviewProps) {
|
||||
const videoDuration = useVideoDuration(props.kind === "video" ? props.videoUrl : undefined);
|
||||
const { text, showCaptions, captionStyle, ttsEnabled, voice, musicUrl, musicVolume, endingEffect } = props;
|
||||
const logoUrl = props.logoUrl ?? undefined;
|
||||
const storeName = endingEffect ? props.storeName || undefined : undefined;
|
||||
const cleanText = cleanCaptionText(text);
|
||||
const voiceDuration = ttsEnabled && cleanText ? voice?.duration ?? estimatedVoiceDuration(text) : 0;
|
||||
const estimated = ttsEnabled && Boolean(cleanText) && !voice;
|
||||
|
||||
const composition = useMemo(() => {
|
||||
if (props.kind === "images") {
|
||||
if (props.images.length === 0) return null;
|
||||
const { total, endingSeconds } = computeImagesTiming({
|
||||
imageCount: props.images.length,
|
||||
voiceDuration,
|
||||
hasEnding: Boolean(logoUrl || storeName),
|
||||
});
|
||||
const contentEnd = total - endingSeconds;
|
||||
let words: TimedWord[] = [];
|
||||
if (showCaptions && cleanText) {
|
||||
words = voice ? voice.words : spreadWords(text, 0, voiceDuration || Math.min(contentEnd - 0.5, 8));
|
||||
}
|
||||
const inputProps: ImageCompositionProps = {
|
||||
images: props.images,
|
||||
totalDuration: total,
|
||||
words,
|
||||
captionStyle,
|
||||
audioUrl: voice?.audioUrl,
|
||||
musicUrl,
|
||||
musicVolume,
|
||||
logoUrl,
|
||||
storeName,
|
||||
endingSeconds,
|
||||
};
|
||||
return { component: ImageComposition, inputProps, total };
|
||||
}
|
||||
|
||||
if (videoDuration == null) return null;
|
||||
const timing = computeReelTiming({
|
||||
videoDuration,
|
||||
voiceDuration: voiceDuration || undefined,
|
||||
hasOutro: Boolean(logoUrl),
|
||||
endingEffect,
|
||||
});
|
||||
let words: TimedWord[] = [];
|
||||
if (showCaptions && cleanText) {
|
||||
if (voice) words = offsetWords(voice.words, VOICE_DELAY);
|
||||
else if (ttsEnabled) words = spreadWords(text, VOICE_DELAY, VOICE_DELAY + voiceDuration);
|
||||
else words = spreadWords(text, 0.5, (timing.logoStart ?? timing.total) - 0.5);
|
||||
}
|
||||
const inputProps: ReelVideoProps = {
|
||||
videoUrl: props.videoUrl,
|
||||
videoDuration,
|
||||
totalDuration: timing.total,
|
||||
words,
|
||||
captionStyle,
|
||||
logoUrl,
|
||||
storeName,
|
||||
logoStart: timing.logoStart,
|
||||
fadeStart: timing.fadeStart,
|
||||
voiceUrl: voice?.audioUrl,
|
||||
voiceDelay: VOICE_DELAY,
|
||||
musicUrl,
|
||||
musicVolume,
|
||||
};
|
||||
return { component: ReelVideo, inputProps, total: timing.total };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
props.kind,
|
||||
props.kind === "video" ? props.videoUrl : props.images.join("|"),
|
||||
videoDuration, text, cleanText, showCaptions, captionStyle, ttsEnabled, voice, voiceDuration,
|
||||
musicUrl, musicVolume, logoUrl, storeName, endingEffect,
|
||||
]);
|
||||
|
||||
if (!composition) {
|
||||
return (
|
||||
<div className="aspect-[9/16] w-full rounded-lg bg-black flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-white/70" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="overflow-hidden rounded-lg bg-black">
|
||||
<Player
|
||||
// Nouvelle instance quand la durée change : le lecteur repart du début
|
||||
key={`${props.kind}-${composition.total}`}
|
||||
component={composition.component as React.ComponentType<Record<string, unknown>>}
|
||||
inputProps={composition.inputProps as unknown as Record<string, unknown>}
|
||||
durationInFrames={Math.max(1, Math.round(composition.total * REEL_FPS))}
|
||||
fps={REEL_FPS}
|
||||
compositionWidth={REEL_WIDTH}
|
||||
compositionHeight={REEL_HEIGHT}
|
||||
style={{ width: "100%", aspectRatio: "9 / 16" }}
|
||||
controls
|
||||
loop
|
||||
acknowledgeRemotionLicense
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground flex items-start gap-1.5">
|
||||
<Info className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
{estimated
|
||||
? "Minutage estimé : cliquez sur « Tester la voix » pour caler les sous-titres sur la vraie voix."
|
||||
: `Aperçu fidèle au rendu final · ${composition.total.toFixed(1)} s`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type TtsEngine,
|
||||
type TtsStyle,
|
||||
} from "@shared/voices";
|
||||
import type { TimedWord } from "@shared/captions";
|
||||
|
||||
export interface VoiceSettings {
|
||||
engine: TtsEngine;
|
||||
@@ -27,12 +28,38 @@ export interface VoiceSettings {
|
||||
style: TtsStyle;
|
||||
}
|
||||
|
||||
/** Voix générée par « Tester la voix », réutilisée par l'aperçu vidéo. */
|
||||
export interface VoicePreviewResult {
|
||||
audioUrl: string;
|
||||
duration: number;
|
||||
words: TimedWord[];
|
||||
/** Texte et réglages ayant servi : l'aperçu n'est valable que s'ils n'ont pas changé. */
|
||||
text: string;
|
||||
settings: VoiceSettings;
|
||||
}
|
||||
|
||||
export function isVoicePreviewCurrent(
|
||||
preview: VoicePreviewResult | null,
|
||||
text: string,
|
||||
settings: VoiceSettings,
|
||||
): preview is VoicePreviewResult {
|
||||
return (
|
||||
!!preview &&
|
||||
preview.text === text.trim() &&
|
||||
preview.settings.engine === settings.engine &&
|
||||
preview.settings.voice === settings.voice &&
|
||||
preview.settings.style === settings.style
|
||||
);
|
||||
}
|
||||
|
||||
interface VoicePickerProps {
|
||||
value: VoiceSettings;
|
||||
onChange: (value: VoiceSettings) => void;
|
||||
/** Texte lu par le bouton « Tester la voix ». */
|
||||
sampleText?: string;
|
||||
compact?: boolean;
|
||||
/** Appelé avec la voix générée (audio et minutage des mots). */
|
||||
onPreview?: (result: VoicePreviewResult) => void;
|
||||
}
|
||||
|
||||
const FALLBACK_SAMPLE = "Découvrez nos nouveautés en magasin, on vous attend !";
|
||||
@@ -41,7 +68,7 @@ const FALLBACK_SAMPLE = "Découvrez nos nouveautés en magasin, on vous attend !
|
||||
* Choix du moteur, de la voix et du ton, avec écoute de l'aperçu.
|
||||
* Partagé par les pages Reel (vidéo et images, bureau et mobile).
|
||||
*/
|
||||
export function VoicePicker({ value, onChange, sampleText, compact = false }: VoicePickerProps) {
|
||||
export function VoicePicker({ value, onChange, sampleText, compact = false, onPreview }: VoicePickerProps) {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
@@ -68,8 +95,9 @@ export function VoicePicker({ value, onChange, sampleText, compact = false }: Vo
|
||||
if (playing) return stop();
|
||||
setLoading(true);
|
||||
try {
|
||||
const text = sampleText?.trim() || FALLBACK_SAMPLE;
|
||||
const response = await apiRequest("POST", "/api/reels/tts-preview", {
|
||||
text: sampleText?.trim() || FALLBACK_SAMPLE,
|
||||
text,
|
||||
ttsEngine: value.engine,
|
||||
ttsVoice: value.voice,
|
||||
ttsStyle: value.style,
|
||||
@@ -78,7 +106,11 @@ export function VoicePicker({ value, onChange, sampleText, compact = false }: Vo
|
||||
for (const warning of data.warnings ?? []) {
|
||||
toast({ title: "Voix de secours utilisée", description: warning });
|
||||
}
|
||||
const audio = new Audio(`data:audio/mpeg;base64,${data.audioBase64}`);
|
||||
const audioUrl = `data:audio/mpeg;base64,${data.audioBase64}`;
|
||||
if (sampleText?.trim()) {
|
||||
onPreview?.({ audioUrl, duration: data.duration, words: data.words ?? [], text, settings: value });
|
||||
}
|
||||
const audio = new Audio(audioUrl);
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => setPlaying(false);
|
||||
|
||||
@@ -16,7 +16,10 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { VoicePicker, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
|
||||
import { ReelPreview } from "@/components/reels/reel-preview";
|
||||
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
|
||||
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
||||
import type { SocialPage, Media } from "@shared/schema";
|
||||
@@ -67,6 +70,10 @@ export default function MobileNewReel() {
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings;
|
||||
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
||||
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
|
||||
// État audio preview
|
||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||
@@ -203,8 +210,10 @@ export default function MobileNewReel() {
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
||||
toast({
|
||||
title: data.success ? "Reel créé !" : "Attention",
|
||||
description: data.success ? "Publication réussie" : "Certaines erreurs",
|
||||
title: "Création du Reel lancée",
|
||||
description: data.queued
|
||||
? "Un autre Reel est en cours : le vôtre démarrera juste après."
|
||||
: "Suivez l'avancement sur l'accueil.",
|
||||
});
|
||||
navigate('/');
|
||||
},
|
||||
@@ -264,6 +273,7 @@ export default function MobileNewReel() {
|
||||
ttsEngine,
|
||||
ttsVoice,
|
||||
ttsStyle,
|
||||
captionStyle,
|
||||
enableEndingEffect,
|
||||
});
|
||||
};
|
||||
@@ -456,6 +466,10 @@ export default function MobileNewReel() {
|
||||
className="text-lg"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="space-y-1.5 mt-3">
|
||||
<Label className="text-xs font-medium">Style des sous-titres</Label>
|
||||
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<Switch
|
||||
id="enable-ending-effect"
|
||||
@@ -490,6 +504,7 @@ export default function MobileNewReel() {
|
||||
value={voiceSettings}
|
||||
onChange={setVoiceSettings}
|
||||
sampleText={overlayText}
|
||||
onPreview={setVoicePreview}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
@@ -524,6 +539,31 @@ export default function MobileNewReel() {
|
||||
{/* PUBLISH STEP */}
|
||||
{currentStep === 'publish' && (
|
||||
<div className="space-y-6">
|
||||
{selectedVideo && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Aperçu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-w-[260px] mx-auto">
|
||||
<ReelPreview
|
||||
kind="video"
|
||||
videoUrl={selectedVideo.originalUrl}
|
||||
text={overlayText}
|
||||
showCaptions
|
||||
captionStyle={captionStyle}
|
||||
ttsEnabled={ttsEnabled}
|
||||
voice={currentVoice}
|
||||
musicUrl={selectedTrack?.previewUrl}
|
||||
musicVolume={musicVolume[0] / 100}
|
||||
logoUrl={reelConfig?.logoUrl}
|
||||
storeName={pages.find((p) => p.id === selectedPages[0])?.pageName}
|
||||
endingEffect={enableEndingEffect}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Diffusion</CardTitle>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { VoicePicker, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
|
||||
import { ReelPreview } from "@/components/reels/reel-preview";
|
||||
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
|
||||
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -50,6 +53,11 @@ export default function MobileRemotionVideoPage() {
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
const [publishDescription, setPublishDescription] = useState("");
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
||||
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
||||
const [renderProgress, setRenderProgress] = useState(0);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const galleryInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -59,6 +67,13 @@ export default function MobileRemotionVideoPage() {
|
||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
||||
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
||||
|
||||
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
|
||||
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
|
||||
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
|
||||
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
|
||||
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
|
||||
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
|
||||
|
||||
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||
|
||||
@@ -142,6 +157,7 @@ export default function MobileRemotionVideoPage() {
|
||||
const handleGenerate = async () => {
|
||||
if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; }
|
||||
setIsRendering(true);
|
||||
setRenderProgress(0);
|
||||
setVideoUrl(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -152,6 +168,7 @@ export default function MobileRemotionVideoPage() {
|
||||
formData.append("ttsEngine", voiceSettings.engine);
|
||||
formData.append("ttsVoice", voiceSettings.voice);
|
||||
formData.append("ttsStyle", voiceSettings.style);
|
||||
formData.append("captionStyle", captionStyle);
|
||||
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
|
||||
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
|
||||
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
|
||||
@@ -180,6 +197,7 @@ export default function MobileRemotionVideoPage() {
|
||||
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
} else {
|
||||
setRenderProgress(job.progress ?? 0);
|
||||
setTimeout(checkStatus, 3000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -328,6 +346,10 @@ export default function MobileRemotionVideoPage() {
|
||||
)}
|
||||
<Textarea placeholder="Texte overlay…" value={overlayText}
|
||||
onChange={e => setOverlayText(e.target.value)} rows={2} className="text-sm" />
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">Style des sous-titres</Label>
|
||||
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="tts-m" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
|
||||
<Label htmlFor="tts-m" className="text-sm">Voix TTS</Label>
|
||||
@@ -336,7 +358,7 @@ export default function MobileRemotionVideoPage() {
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">TTS — voix activée</p>
|
||||
|
||||
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} compact />
|
||||
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} compact />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -433,10 +455,37 @@ export default function MobileRemotionVideoPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Aperçu en direct */}
|
||||
{previewImages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Aperçu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-w-[260px] mx-auto">
|
||||
<ReelPreview
|
||||
kind="images"
|
||||
images={previewImages}
|
||||
text={overlayText}
|
||||
showCaptions
|
||||
captionStyle={captionStyle}
|
||||
ttsEnabled={ttsEnabled}
|
||||
voice={currentVoice}
|
||||
musicUrl={musicFileUrl ?? selectedTrack?.url}
|
||||
musicVolume={musicVolume}
|
||||
logoUrl={reelConfig?.logoUrl}
|
||||
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
|
||||
endingEffect
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Générer */}
|
||||
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12" size="lg">
|
||||
{isRendering
|
||||
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Génération (1-2 min)…</>
|
||||
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
|
||||
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>}
|
||||
</Button>
|
||||
|
||||
|
||||
+38
-139
@@ -25,7 +25,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { VoicePicker, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
|
||||
import { ReelPreview } from "@/components/reels/reel-preview";
|
||||
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
|
||||
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||
import type { SocialPage, Media } from "@shared/schema";
|
||||
@@ -89,6 +92,11 @@ export default function NewReel() {
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings;
|
||||
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
||||
// Dernière voix générée : l'aperçu l'utilise tant que texte et réglages n'ont pas changé
|
||||
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
|
||||
// Enable TTS by default on mobile
|
||||
useEffect(() => {
|
||||
@@ -245,10 +253,10 @@ export default function NewReel() {
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
||||
toast({
|
||||
title: data.success ? "Reel créé !" : "Reel créé avec avertissements",
|
||||
description: data.success
|
||||
? "Votre Reel a été publié avec succès"
|
||||
: "Certaines pages ont échoué",
|
||||
title: "Création du Reel lancée",
|
||||
description: data.queued
|
||||
? "Un autre Reel est en cours : le vôtre démarrera juste après. Suivez l'avancement sur le tableau de bord."
|
||||
: "Suivez l'avancement sur le tableau de bord.",
|
||||
});
|
||||
navigate('/');
|
||||
},
|
||||
@@ -406,6 +414,7 @@ export default function NewReel() {
|
||||
ttsEngine,
|
||||
ttsVoice,
|
||||
ttsStyle,
|
||||
captionStyle,
|
||||
drawText,
|
||||
stabilize: stabilize,
|
||||
enableEndingEffect,
|
||||
@@ -783,6 +792,15 @@ export default function NewReel() {
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{drawText && (
|
||||
<div className="mt-3 ml-12">
|
||||
<Label className="text-sm font-medium">Style des sous-titres</Label>
|
||||
<div className="mt-2">
|
||||
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<Switch
|
||||
id="enable-ending-effect"
|
||||
@@ -817,6 +835,7 @@ export default function NewReel() {
|
||||
value={voiceSettings}
|
||||
onChange={setVoiceSettings}
|
||||
sampleText={overlayText}
|
||||
onPreview={setVoicePreview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -962,31 +981,20 @@ export default function NewReel() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedVideo ? (
|
||||
<div className="relative aspect-[9/16] bg-black rounded-lg overflow-hidden">
|
||||
<video
|
||||
src={selectedVideo.originalUrl}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
loop
|
||||
autoPlay
|
||||
/>
|
||||
{overlayText && (
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<p className="text-white text-center text-lg font-bold drop-shadow-lg">
|
||||
{overlayText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedTrack && (
|
||||
<div className="absolute bottom-4 left-4 right-4 bg-black/50 rounded-lg p-2 flex items-center gap-2">
|
||||
<Music className="w-4 h-4 text-white" />
|
||||
<span className="text-white text-sm truncate">
|
||||
{selectedTrack.title} - {selectedTrack.artist}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ReelPreview
|
||||
kind="video"
|
||||
videoUrl={selectedVideo.originalUrl}
|
||||
text={overlayText}
|
||||
showCaptions={drawText}
|
||||
captionStyle={captionStyle}
|
||||
ttsEnabled={ttsEnabled}
|
||||
voice={currentVoice}
|
||||
musicUrl={selectedTrack?.previewUrl}
|
||||
musicVolume={musicVolume[0] / 100}
|
||||
logoUrl={reelConfig?.logoUrl}
|
||||
storeName={pages.find((p) => p.id === selectedPages[0])?.pageName}
|
||||
endingEffect={enableEndingEffect}
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-[9/16] bg-muted rounded-lg flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
@@ -1026,115 +1034,6 @@ export default function NewReel() {
|
||||
</div >
|
||||
</div >
|
||||
</main >
|
||||
{/* Overlay de progression */}
|
||||
< ProcessingOverlay
|
||||
isVisible={createReelMutation.isPending}
|
||||
stabilize={stabilize}
|
||||
ttsEnabled={ttsEnabled}
|
||||
/>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
function ProcessingOverlay({ isVisible, stabilize, ttsEnabled }: { isVisible: boolean; stabilize: boolean; ttsEnabled: boolean }) {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [status, setStatus] = useState("Initialisation...");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
setProgress(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(0);
|
||||
setStatus("Préparation des fichiers...");
|
||||
|
||||
const timeouts: any[] = [];
|
||||
let interval: any;
|
||||
|
||||
// Sequence de simulation
|
||||
// 1. 2s: Téléchargement
|
||||
timeouts.push(setTimeout(() => {
|
||||
setStatus("Téléchargement des médias...");
|
||||
setProgress(10);
|
||||
}, 1500));
|
||||
|
||||
// 2. 4s: Audio/TTS
|
||||
timeouts.push(setTimeout(() => {
|
||||
setStatus(ttsEnabled ? "Génération de la voix IA..." : "Mixage audio...");
|
||||
setProgress(25);
|
||||
}, 3500));
|
||||
|
||||
// 3. 7s: Stabilisation ou Encodage
|
||||
timeouts.push(setTimeout(() => {
|
||||
if (stabilize) {
|
||||
setStatus("Stabilisation vidéo (Traitement long)...");
|
||||
setProgress(35);
|
||||
|
||||
// Progression lente : +1% toutes les 800ms
|
||||
// Ça permet de couvrir ~45 secondes avant d'arriver à 90%
|
||||
let p = 35;
|
||||
interval = setInterval(() => {
|
||||
if (p < 90) {
|
||||
p++;
|
||||
setProgress(p);
|
||||
}
|
||||
}, 800);
|
||||
} else {
|
||||
setStatus("Encodage optimisé...");
|
||||
setProgress(40);
|
||||
|
||||
// Progression pour encodage standard (~10-15s)
|
||||
let p = 40;
|
||||
interval = setInterval(() => {
|
||||
if (p < 90) {
|
||||
p += 2;
|
||||
setProgress(p);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 6500));
|
||||
|
||||
// Nettoyage
|
||||
return () => {
|
||||
timeouts.forEach(clearTimeout);
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [isVisible, stabilize, ttsEnabled]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] bg-black/90 flex flex-col items-center justify-center p-4 backdrop-blur-md animate-in fade-in duration-300">
|
||||
<div className="w-full max-w-md space-y-8 text-center">
|
||||
<div className="relative w-24 h-24 mx-auto mb-8">
|
||||
<Loader2 className="w-full h-full text-primary animate-spin" />
|
||||
<div className="absolute inset-0 flex items-center justify-center font-bold text-xl">
|
||||
{progress}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-2xl font-bold text-white tracking-tight">Création de votre Reel</h3>
|
||||
<p className="text-lg text-muted-foreground animate-pulse">
|
||||
{status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-2 bg-secondary/30 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{stabilize && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-yellow-500/80 bg-yellow-500/10 py-2 px-4 rounded-full mx-auto w-fit">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>Stabilisation activée : traitement prolongé</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { VoicePicker, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
|
||||
import { ReelPreview } from "@/components/reels/reel-preview";
|
||||
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
|
||||
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -51,6 +54,11 @@ export default function RemotionVideoPage() {
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
const [publishDescription, setPublishDescription] = useState("");
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
||||
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
||||
const [renderProgress, setRenderProgress] = useState(0);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -58,6 +66,13 @@ export default function RemotionVideoPage() {
|
||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
||||
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
||||
|
||||
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
|
||||
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
|
||||
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
|
||||
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
|
||||
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
|
||||
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
|
||||
|
||||
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||
|
||||
@@ -108,6 +123,7 @@ export default function RemotionVideoPage() {
|
||||
const handleGenerate = async () => {
|
||||
if (totalSelected < 3) { toast({ title: "Minimum 3 images requises.", variant: "destructive" }); return; }
|
||||
setIsRendering(true);
|
||||
setRenderProgress(0);
|
||||
setVideoUrl(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -118,6 +134,7 @@ export default function RemotionVideoPage() {
|
||||
formData.append("ttsEngine", voiceSettings.engine);
|
||||
formData.append("ttsVoice", voiceSettings.voice);
|
||||
formData.append("ttsStyle", voiceSettings.style);
|
||||
formData.append("captionStyle", captionStyle);
|
||||
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
|
||||
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
|
||||
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
|
||||
@@ -146,6 +163,7 @@ export default function RemotionVideoPage() {
|
||||
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
} else {
|
||||
setRenderProgress(job.progress ?? 0);
|
||||
setTimeout(checkStatus, 3000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -268,6 +286,10 @@ export default function RemotionVideoPage() {
|
||||
)}
|
||||
<Textarea placeholder="Votre texte overlay..." value={overlayText}
|
||||
onChange={e => setOverlayText(e.target.value)} rows={3} />
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">Style des sous-titres</Label>
|
||||
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="tts" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
|
||||
<Label htmlFor="tts">Activer la voix TTS</Label>
|
||||
@@ -276,7 +298,7 @@ export default function RemotionVideoPage() {
|
||||
<div className="space-y-3 p-3 bg-muted/30 rounded-lg border">
|
||||
<p className="text-sm text-muted-foreground">TTS — voix activée</p>
|
||||
|
||||
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} />
|
||||
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -379,10 +401,37 @@ export default function RemotionVideoPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Aperçu en direct */}
|
||||
{previewImages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Aperçu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-w-[300px] mx-auto">
|
||||
<ReelPreview
|
||||
kind="images"
|
||||
images={previewImages}
|
||||
text={overlayText}
|
||||
showCaptions
|
||||
captionStyle={captionStyle}
|
||||
ttsEnabled={ttsEnabled}
|
||||
voice={currentVoice}
|
||||
musicUrl={musicFileUrl ?? selectedTrack?.url}
|
||||
musicVolume={musicVolume}
|
||||
logoUrl={reelConfig?.logoUrl}
|
||||
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
|
||||
endingEffect
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Générer */}
|
||||
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full" size="lg">
|
||||
{isRendering
|
||||
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Génération en cours (1-2 min)...</>
|
||||
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
|
||||
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo</>}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { AbsoluteFill, Img, Sequence, useVideoConfig, useCurrentFrame, Html5Audio, spring, interpolate } from "remotion";
|
||||
|
||||
export type WordTiming = {
|
||||
word: string;
|
||||
startFrame: number;
|
||||
endFrame: number;
|
||||
};
|
||||
import React from "react";
|
||||
import { AbsoluteFill, Html5Audio, Img, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import type { CaptionStyle, TimedWord } from "@shared/captions";
|
||||
import { Captions } from "./components/Captions";
|
||||
import { Outro, Watermark } from "./components/Branding";
|
||||
|
||||
export type ImageCompositionProps = {
|
||||
images: string[];
|
||||
overlayText?: string;
|
||||
totalDuration: number;
|
||||
/** Mots minutés depuis le début de la vidéo (la voix démarre à 0). */
|
||||
words: TimedWord[];
|
||||
captionStyle: CaptionStyle;
|
||||
audioUrl?: string;
|
||||
wordTimings?: WordTiming[];
|
||||
musicUrl?: string;
|
||||
musicVolume?: number;
|
||||
logoUrl?: string;
|
||||
storeName?: string;
|
||||
endingFrames?: number;
|
||||
/** Durée de la diapositive de fin (logo + nom du magasin), en secondes. */
|
||||
endingSeconds?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -75,217 +76,45 @@ const ImageSlide: React.FC<{ src: string; effectIndex: number }> = ({ src, effec
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CaptionGroup — renders a group of words (CapCut-style).
|
||||
* Active word is highlighted in yellow with glow; others are white.
|
||||
* Background: semi-transparent dark pill.
|
||||
*/
|
||||
const CaptionGroup: React.FC<{
|
||||
words: WordTiming[];
|
||||
activeIdx: number; // index within this group (0-3), -1 if none active
|
||||
groupFirstFrame: number;
|
||||
fps: number;
|
||||
}> = ({ words, activeIdx, groupFirstFrame, fps }) => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// Fade in when this group first appears
|
||||
const fadeIn = spring({
|
||||
frame: frame - groupFirstFrame,
|
||||
fps,
|
||||
config: { damping: 30, stiffness: 120 },
|
||||
durationInFrames: 10,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: "0 22px",
|
||||
maxWidth: "90%",
|
||||
opacity: fadeIn,
|
||||
}}
|
||||
>
|
||||
{words.map((w, i) => {
|
||||
const isActive = i === activeIdx;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
fontFamily: "'Arial Black', 'Impact', 'Helvetica Neue', sans-serif",
|
||||
fontSize: isActive ? 82 : 74,
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.15,
|
||||
color: isActive ? "#FFE600" : "white",
|
||||
textTransform: "uppercase",
|
||||
textShadow: isActive
|
||||
? "0 0 30px rgba(255,230,0,0.6), 0 4px 24px rgba(0,0,0,0.95)"
|
||||
: "0 3px 18px rgba(0,0,0,0.9)",
|
||||
WebkitTextStroke: isActive ? "3px rgba(0,0,0,0.85)" : "2px rgba(0,0,0,0.75)",
|
||||
display: "inline-block",
|
||||
transform: isActive ? "scale(1.1)" : "scale(1)",
|
||||
transition: "transform 0.05s",
|
||||
}}
|
||||
>
|
||||
{w.word}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImageComposition = ({
|
||||
images, audioUrl, wordTimings, musicUrl, musicVolume = 0.3,
|
||||
logoUrl, storeName, endingFrames = 90,
|
||||
}: ImageCompositionProps) => {
|
||||
/** Reel à partir d'images : diaporama animé, voix, sous-titres et diapositive de fin. */
|
||||
export const ImageComposition: React.FC<ImageCompositionProps> = ({
|
||||
images, words, captionStyle, audioUrl, musicUrl, musicVolume = 0.3, logoUrl, storeName, endingSeconds = 3,
|
||||
}) => {
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
const hasEnding = !!(logoUrl || storeName);
|
||||
const effectiveEndingFrames = hasEnding ? endingFrames : 0;
|
||||
const contentFrames = durationInFrames - effectiveEndingFrames;
|
||||
const durationPerImage = Math.floor(contentFrames / Math.max(images.length, 1));
|
||||
const endingStart = contentFrames;
|
||||
|
||||
// Ending slide spring animation
|
||||
const endingProgress = spring({
|
||||
frame: frame - endingStart,
|
||||
fps,
|
||||
config: { damping: 20, stiffness: 80 },
|
||||
durationInFrames: 30,
|
||||
});
|
||||
|
||||
// Group words into sets of 3 for caption display
|
||||
const WORDS_PER_GROUP = 3;
|
||||
const activeWordIdx = wordTimings
|
||||
? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame)
|
||||
: -1;
|
||||
|
||||
// Only show caption when a word is actively being spoken
|
||||
const renderCaption = frame < endingStart && activeWordIdx >= 0;
|
||||
|
||||
const groupIdx = activeWordIdx >= 0 ? Math.floor(activeWordIdx / WORDS_PER_GROUP) : 0;
|
||||
const groupStart = groupIdx * WORDS_PER_GROUP;
|
||||
const groupWords = wordTimings ? wordTimings.slice(groupStart, groupStart + WORDS_PER_GROUP) : [];
|
||||
const activeIdxInGroup = activeWordIdx - groupStart;
|
||||
|
||||
// First frame of the current group (for fade-in)
|
||||
const groupFirstFrame = groupWords.length > 0 ? groupWords[0].startFrame : 0;
|
||||
const hasEnding = Boolean(logoUrl || storeName) && endingSeconds > 0;
|
||||
const endingFrames = hasEnding ? Math.round(endingSeconds * fps) : 0;
|
||||
const contentFrames = durationInFrames - endingFrames;
|
||||
const perImage = Math.floor(contentFrames / Math.max(images.length, 1));
|
||||
const endingStart = contentFrames / fps;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||
{/* Images — displayed immediately, no entrance transition */}
|
||||
{images.map((imgUrl, index) => (
|
||||
<Sequence key={index} from={index * durationPerImage} durationInFrames={durationPerImage}>
|
||||
<Sequence key={index} from={index * perImage} durationInFrames={perImage}>
|
||||
<ImageSlide src={imgUrl} effectIndex={index} />
|
||||
</Sequence>
|
||||
))}
|
||||
|
||||
{/* TTS Audio */}
|
||||
{audioUrl && <Html5Audio src={audioUrl} />}
|
||||
|
||||
{/* Background music */}
|
||||
{musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />}
|
||||
|
||||
{/* Caption overlay — CapCut style */}
|
||||
{renderCaption && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
paddingBottom: 180,
|
||||
}}
|
||||
>
|
||||
{/* Semi-transparent dark background pill */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
borderRadius: 28,
|
||||
paddingTop: 22,
|
||||
paddingBottom: 22,
|
||||
paddingLeft: 36,
|
||||
paddingRight: 36,
|
||||
maxWidth: "92%",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
<CaptionGroup
|
||||
words={groupWords}
|
||||
activeIdx={activeIdxInGroup}
|
||||
groupFirstFrame={groupFirstFrame}
|
||||
fps={fps}
|
||||
/>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
{musicUrl && (
|
||||
<Html5Audio
|
||||
src={musicUrl}
|
||||
loop
|
||||
volume={(frame) =>
|
||||
(words.some((w) => frame / fps >= w.start - 0.2 && frame / fps <= w.end + 0.3)
|
||||
? musicVolume * 0.35
|
||||
: musicVolume) *
|
||||
interpolate(frame, [durationInFrames - fps, durationInFrames], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Logo watermark — bottom right during content */}
|
||||
{logoUrl && frame < endingStart && (
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "flex-end", padding: 48 }}>
|
||||
<Img
|
||||
src={logoUrl}
|
||||
style={{
|
||||
width: 150,
|
||||
height: 150,
|
||||
objectFit: "contain",
|
||||
opacity: 0.85,
|
||||
borderRadius: 20,
|
||||
filter: "drop-shadow(0 2px 8px rgba(0,0,0,0.5))",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* Ending slide — large centered logo + store name */}
|
||||
{hasEnding && (
|
||||
<Sequence from={endingStart} durationInFrames={effectiveEndingFrames}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: "black",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
gap: 48,
|
||||
}}
|
||||
>
|
||||
{logoUrl && (
|
||||
<Img
|
||||
src={logoUrl}
|
||||
style={{
|
||||
width: 520,
|
||||
height: 520,
|
||||
objectFit: "contain",
|
||||
opacity: endingProgress,
|
||||
transform: `scale(${interpolate(endingProgress, [0, 1], [0.65, 1])})`,
|
||||
borderRadius: 32,
|
||||
filter: "drop-shadow(0 4px 32px rgba(255,255,255,0.2))",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{storeName && (
|
||||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 90,
|
||||
fontFamily: "'Arial Black', Impact, sans-serif",
|
||||
fontWeight: 900,
|
||||
textAlign: "center",
|
||||
opacity: endingProgress,
|
||||
letterSpacing: 3,
|
||||
textShadow: "0 4px 24px rgba(255,255,255,0.2)",
|
||||
transform: `translateY(${interpolate(endingProgress, [0, 1], [30, 0])}px)`,
|
||||
}}
|
||||
>
|
||||
{storeName}
|
||||
</div>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
</Sequence>
|
||||
)}
|
||||
<Captions words={words} style={captionStyle} hideAfter={hasEnding ? endingStart : null} />
|
||||
{logoUrl && <Watermark logoUrl={logoUrl} until={hasEnding ? endingStart : null} />}
|
||||
{hasEnding && <Outro start={endingStart} logoUrl={logoUrl} storeName={storeName} opaque />}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, Freeze, Html5Audio, OffthreadVideo, Sequence, interpolate, useVideoConfig } from "remotion";
|
||||
import { FADE_SECONDS, type CaptionStyle, type TimedWord } from "@shared/captions";
|
||||
import { Captions } from "./components/Captions";
|
||||
import { FadeOut, Outro, Watermark } from "./components/Branding";
|
||||
|
||||
export type ReelVideoProps = {
|
||||
videoUrl: string;
|
||||
/** Durée de la vidéo source (s) : au-delà, la dernière image est figée (aperçu). */
|
||||
videoDuration: number;
|
||||
totalDuration: number;
|
||||
words: TimedWord[];
|
||||
captionStyle: CaptionStyle;
|
||||
logoUrl?: string;
|
||||
storeName?: string;
|
||||
logoStart?: number | null;
|
||||
fadeStart?: number | null;
|
||||
/** Rendu final : piste son déjà mixée (voix, musique baissée sous la voix, niveau normalisé). */
|
||||
mixedAudioUrl?: string;
|
||||
/** Aperçu : pistes séparées, mixées dans le navigateur. */
|
||||
voiceUrl?: string;
|
||||
voiceDelay?: number;
|
||||
musicUrl?: string;
|
||||
musicVolume?: number;
|
||||
};
|
||||
|
||||
/** Reel à partir d'une vidéo : image, sous-titres animés, logo et effet de fin. */
|
||||
export const ReelVideo: React.FC<ReelVideoProps> = (props) => {
|
||||
const { fps } = useVideoConfig();
|
||||
const {
|
||||
videoUrl, videoDuration, totalDuration, words, captionStyle, logoUrl, storeName,
|
||||
logoStart = null, fadeStart = null, mixedAudioUrl, voiceUrl, voiceDelay = 2, musicUrl, musicVolume = 0.25,
|
||||
} = props;
|
||||
|
||||
const videoFrames = Math.max(1, Math.floor(videoDuration * fps));
|
||||
const totalFrames = Math.max(1, Math.round(totalDuration * fps));
|
||||
const hasAddedAudio = Boolean(mixedAudioUrl || voiceUrl || musicUrl);
|
||||
const video = (
|
||||
<OffthreadVideo src={videoUrl} muted={hasAddedAudio} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||
{totalFrames > videoFrames ? (
|
||||
<>
|
||||
<Sequence durationInFrames={videoFrames}>{video}</Sequence>
|
||||
<Sequence from={videoFrames}>
|
||||
<Freeze frame={videoFrames - 1}>{video}</Freeze>
|
||||
</Sequence>
|
||||
</>
|
||||
) : (
|
||||
video
|
||||
)}
|
||||
|
||||
{mixedAudioUrl && <Html5Audio src={mixedAudioUrl} />}
|
||||
{!mixedAudioUrl && voiceUrl && (
|
||||
<Sequence from={Math.round(voiceDelay * fps)}>
|
||||
<Html5Audio src={voiceUrl} />
|
||||
</Sequence>
|
||||
)}
|
||||
{!mixedAudioUrl && musicUrl && (
|
||||
<Html5Audio
|
||||
src={musicUrl}
|
||||
loop
|
||||
volume={(frame) => previewMusicVolume(frame / fps, words, musicVolume, fadeStart)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Captions words={words} style={captionStyle} hideAfter={logoStart} />
|
||||
{logoUrl && <Watermark logoUrl={logoUrl} until={logoStart} />}
|
||||
{logoStart != null && (logoUrl || storeName) && (
|
||||
<Outro start={logoStart} logoUrl={logoUrl} storeName={storeName} />
|
||||
)}
|
||||
{fadeStart != null && <FadeOut start={fadeStart} duration={FADE_SECONDS} />}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Aperçu : la musique baisse pendant la parole et s'éteint avec le fondu final. */
|
||||
function previewMusicVolume(time: number, words: TimedWord[], base: number, fadeStart: number | null): number {
|
||||
const speaking = words.some((w) => time >= w.start - 0.2 && time <= w.end + 0.3);
|
||||
const fade =
|
||||
fadeStart == null
|
||||
? 1
|
||||
: interpolate(time, [fadeStart, fadeStart + FADE_SECONDS], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
return (speaking ? base * 0.35 : base) * fade;
|
||||
}
|
||||
@@ -1,22 +1,45 @@
|
||||
import { Composition } from "remotion";
|
||||
import { ImageComposition, ImageCompositionProps } from "./ImageComposition";
|
||||
import { Composition, type CalculateMetadataFunction } from "remotion";
|
||||
import { DEFAULT_CAPTION_STYLE, REEL_FPS, REEL_HEIGHT, REEL_WIDTH } from "@shared/captions";
|
||||
import { ImageComposition, type ImageCompositionProps } from "./ImageComposition";
|
||||
import { ReelVideo, type ReelVideoProps } from "./ReelVideo";
|
||||
|
||||
/** La durée vient des propriétés : chaque Reel a la sienne. */
|
||||
function byTotalDuration<P extends { totalDuration: number }>(): CalculateMetadataFunction<P> {
|
||||
return ({ props }) => ({ durationInFrames: Math.max(1, Math.round(props.totalDuration * REEL_FPS)) });
|
||||
}
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Composition
|
||||
id="ReelVideo"
|
||||
component={ReelVideo}
|
||||
fps={REEL_FPS}
|
||||
width={REEL_WIDTH}
|
||||
height={REEL_HEIGHT}
|
||||
durationInFrames={REEL_FPS * 10}
|
||||
calculateMetadata={byTotalDuration<ReelVideoProps>()}
|
||||
defaultProps={{
|
||||
videoUrl: "",
|
||||
videoDuration: 10,
|
||||
totalDuration: 10,
|
||||
words: [],
|
||||
captionStyle: DEFAULT_CAPTION_STYLE,
|
||||
} as ReelVideoProps}
|
||||
/>
|
||||
<Composition
|
||||
id="ImageVideo"
|
||||
component={ImageComposition}
|
||||
durationInFrames={360}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
fps={REEL_FPS}
|
||||
width={REEL_WIDTH}
|
||||
height={REEL_HEIGHT}
|
||||
durationInFrames={REEL_FPS * 25}
|
||||
calculateMetadata={byTotalDuration<ImageCompositionProps>()}
|
||||
defaultProps={{
|
||||
images: [
|
||||
"https://placehold.co/1080x1920?text=Image+1",
|
||||
"https://placehold.co/1080x1920?text=Image+2",
|
||||
"https://placehold.co/1080x1920?text=Image+3"
|
||||
],
|
||||
images: ["https://placehold.co/1080x1920?text=Image+1"],
|
||||
totalDuration: 25,
|
||||
words: [],
|
||||
captionStyle: DEFAULT_CAPTION_STYLE,
|
||||
} as ImageCompositionProps}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, Img, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { CAPTION_FONT } from "../fonts";
|
||||
|
||||
/** Petit logo en haut à droite, hors des zones couvertes par l'interface des réseaux. */
|
||||
export const Watermark: React.FC<{ logoUrl: string; until?: number | null }> = ({ logoUrl, until }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
if (until != null && frame / fps >= until) return null;
|
||||
return (
|
||||
<AbsoluteFill style={{ alignItems: "flex-end", justifyContent: "flex-start", padding: "150px 48px" }}>
|
||||
<Img
|
||||
src={logoUrl}
|
||||
style={{
|
||||
width: 150,
|
||||
height: 150,
|
||||
objectFit: "contain",
|
||||
opacity: 0.9,
|
||||
filter: "drop-shadow(0 2px 10px rgba(0,0,0,0.55))",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Effet de fin : l'image s'assombrit, le logo apparaît en grand puis le nom
|
||||
* du magasin glisse en dessous.
|
||||
*/
|
||||
export const Outro: React.FC<{ start: number; logoUrl?: string; storeName?: string; opaque?: boolean }> = ({
|
||||
start,
|
||||
logoUrl,
|
||||
storeName,
|
||||
opaque = false,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const local = frame - Math.round(start * fps);
|
||||
if (local < 0) return null;
|
||||
|
||||
const enter = spring({ frame: local, fps, config: { damping: 18, stiffness: 90 }, durationInFrames: 24 });
|
||||
const name = spring({ frame: local - 8, fps, config: { damping: 18, stiffness: 90 }, durationInFrames: 24 });
|
||||
const dim = interpolate(local, [0, 12], [0, opaque ? 1 : 0.62], { extrapolateRight: "clamp" });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", gap: 44 }}>
|
||||
<AbsoluteFill style={{ backgroundColor: `rgba(0,0,0,${dim})` }} />
|
||||
{logoUrl && (
|
||||
<Img
|
||||
src={logoUrl}
|
||||
style={{
|
||||
width: 480,
|
||||
height: 480,
|
||||
objectFit: "contain",
|
||||
opacity: enter,
|
||||
transform: `scale(${interpolate(enter, [0, 1], [0.7, 1])})`,
|
||||
filter: "drop-shadow(0 6px 30px rgba(0,0,0,0.5))",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{storeName && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: CAPTION_FONT,
|
||||
fontWeight: 800,
|
||||
fontSize: 84,
|
||||
color: "#FFFFFF",
|
||||
textAlign: "center",
|
||||
maxWidth: "86%",
|
||||
lineHeight: 1.1,
|
||||
opacity: name,
|
||||
transform: `translateY(${interpolate(name, [0, 1], [30, 0])}px)`,
|
||||
textShadow: "0 4px 24px rgba(0,0,0,0.6)",
|
||||
}}
|
||||
>
|
||||
{storeName}
|
||||
</div>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Fondu au noir final. */
|
||||
export const FadeOut: React.FC<{ start: number; duration: number }> = ({ start, duration }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const opacity = interpolate(frame / fps, [start, start + duration], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
return opacity > 0 ? <AbsoluteFill style={{ backgroundColor: `rgba(0,0,0,${opacity})` }} /> : null;
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { groupLines, type CaptionStyle, type TimedWord } from "@shared/captions";
|
||||
import { CAPTION_FONT } from "../fonts";
|
||||
|
||||
interface CaptionsProps {
|
||||
words: TimedWord[];
|
||||
style: CaptionStyle;
|
||||
/** Les sous-titres disparaissent à partir de cet instant (effet de fin). */
|
||||
hideAfter?: number | null;
|
||||
}
|
||||
|
||||
/** Couleur de mise en avant du mot prononcé, par style. */
|
||||
const ACCENT = { bold: "#FFE600", neon: "#7C3AED", minimal: "#FFFFFF" } as const;
|
||||
|
||||
/**
|
||||
* Sous-titres mot à mot, synchronisés sur la voix.
|
||||
* Une ligne de 1 à 3 mots à la fois, placée au-dessus des boutons qu'Instagram
|
||||
* et TikTok superposent en bas de l'écran.
|
||||
*/
|
||||
export const Captions: React.FC<CaptionsProps> = ({ words, style, hideAfter }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const time = frame / fps;
|
||||
const lines = useMemo(() => groupLines(words), [words]);
|
||||
|
||||
if (hideAfter != null && time >= hideAfter) return null;
|
||||
|
||||
// Ligne courante : du début de son premier mot au début de la suivante
|
||||
const index = lines.findIndex((line, i) => {
|
||||
const start = line[0].start;
|
||||
const next = lines[i + 1]?.[0].start ?? line[line.length - 1].end + 0.4;
|
||||
return time >= start && time < next;
|
||||
});
|
||||
if (index < 0) return null;
|
||||
const line = lines[index];
|
||||
|
||||
const lineFrame = Math.round(line[0].start * fps);
|
||||
const appear = spring({ frame: frame - lineFrame, fps, config: { damping: 14, stiffness: 180 }, durationInFrames: 8 });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 520 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: style === "neon" ? "12px 12px" : style === "bold" ? "4px 38px" : "0 24px",
|
||||
maxWidth: "84%",
|
||||
transform: `scale(${0.85 + 0.15 * appear})`,
|
||||
opacity: appear,
|
||||
}}
|
||||
>
|
||||
{line.map((word, i) => {
|
||||
const spoken = time >= word.start;
|
||||
const active = spoken && (i === line.length - 1 || time < line[i + 1].start);
|
||||
const pop = spring({
|
||||
frame: frame - Math.round(word.start * fps),
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 220 },
|
||||
durationInFrames: 6,
|
||||
});
|
||||
return (
|
||||
<Word key={`${index}-${i}`} text={word.text} style={style} active={active} spoken={spoken} pop={pop} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const Word: React.FC<{ text: string; style: CaptionStyle; active: boolean; spoken: boolean; pop: number }> = ({
|
||||
text,
|
||||
style,
|
||||
active,
|
||||
spoken,
|
||||
pop,
|
||||
}) => {
|
||||
const base: React.CSSProperties = {
|
||||
fontFamily: CAPTION_FONT,
|
||||
display: "inline-block",
|
||||
lineHeight: 1.1,
|
||||
textAlign: "center",
|
||||
paintOrder: "stroke fill",
|
||||
};
|
||||
|
||||
if (style === "bold") {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
...base,
|
||||
fontSize: 92,
|
||||
fontWeight: 900,
|
||||
textTransform: "uppercase",
|
||||
color: active ? ACCENT.bold : "#FFFFFF",
|
||||
WebkitTextStroke: "12px #000000",
|
||||
textShadow: "0 8px 24px rgba(0,0,0,0.55)",
|
||||
transform: `scale(${active ? 1 + 0.12 * pop : 1})`,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (style === "neon") {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
...base,
|
||||
fontSize: 80,
|
||||
fontWeight: 800,
|
||||
color: "#FFFFFF",
|
||||
padding: "6px 18px",
|
||||
borderRadius: 18,
|
||||
backgroundColor: active ? ACCENT.neon : "rgba(0,0,0,0.45)",
|
||||
boxShadow: active ? "0 0 36px rgba(124,58,237,0.75)" : "none",
|
||||
transform: `scale(${active ? 1 + 0.08 * pop : 1})`,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
...base,
|
||||
fontSize: 78,
|
||||
fontWeight: 700,
|
||||
color: ACCENT.minimal,
|
||||
opacity: spoken ? 1 : 0.45,
|
||||
textShadow: "0 4px 18px rgba(0,0,0,0.85), 0 0 2px rgba(0,0,0,0.9)",
|
||||
transform: `translateY(${active ? -6 * pop : 0}px)`,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Police des sous-titres et de l'effet de fin, embarquée dans le bundle : le
|
||||
* rendu ne dépend d'aucun service externe (Google Fonts).
|
||||
*
|
||||
* Chargée en CSS (@font-face) plutôt qu'avec delayRender : un onglet de rendu
|
||||
* rechargé en cours de route restait bloqué sur l'attente de la police. Les
|
||||
* sous-titres n'apparaissent qu'après le délai de la voix, la police est
|
||||
* chargée bien avant.
|
||||
*/
|
||||
import "@fontsource/montserrat/700.css";
|
||||
import "@fontsource/montserrat/800.css";
|
||||
import "@fontsource/montserrat/900.css";
|
||||
|
||||
export const CAPTION_FONT = "Montserrat, 'Arial Black', Arial, sans-serif";
|
||||
Reference in new issue
Block a user