diff --git a/client/src/components/reels/reel-preview.tsx b/client/src/components/reels/reel-preview.tsx index 70d461d..ac66911 100644 --- a/client/src/components/reels/reel-preview.tsx +++ b/client/src/components/reels/reel-preview.tsx @@ -32,6 +32,8 @@ interface CommonProps { musicUrl?: string; musicVolume: number; logoUrl?: string | null; + /** Petit logo pendant la vidéo (le grand logo de fin dépend de l'effet de fin). */ + showWatermark: boolean; storeName?: string; endingEffect: boolean; /** Sous-titres SRT : remplacent `text`, minutés par le fichier. */ @@ -74,7 +76,8 @@ function estimatedVoiceDuration(text: string): number { */ 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 { text, showCaptions, captionStyle, ttsEnabled, voice, musicUrl, musicVolume, endingEffect, showWatermark } = + props; const logoUrl = props.logoUrl ?? undefined; const storeName = endingEffect ? props.storeName || undefined : undefined; const srtCues = props.kind === "video" && props.srtCues?.length ? props.srtCues : null; @@ -106,6 +109,7 @@ export function ReelPreview(props: ReelPreviewProps) { musicUrl, musicVolume, logoUrl, + showWatermark, storeName, endingSeconds, }; @@ -136,6 +140,7 @@ export function ReelPreview(props: ReelPreviewProps) { words, captionStyle, logoUrl, + showWatermark, storeName, logoStart: timing.logoStart, fadeStart: timing.fadeStart, @@ -150,7 +155,7 @@ export function ReelPreview(props: ReelPreviewProps) { props.kind, props.kind === "video" ? props.videoUrl : props.images.join("|"), videoDuration, text, cleanText, srtCues, showCaptions, captionStyle, ttsEnabled, voice, voiceDuration, - musicUrl, musicVolume, logoUrl, storeName, endingEffect, + musicUrl, musicVolume, logoUrl, showWatermark, storeName, endingEffect, ]); if (!composition) { diff --git a/client/src/pages/mobile/new-reel.tsx b/client/src/pages/mobile/new-reel.tsx index 83d51cc..47f7cbd 100644 --- a/client/src/pages/mobile/new-reel.tsx +++ b/client/src/pages/mobile/new-reel.tsx @@ -84,6 +84,7 @@ export default function MobileNewReel() { // Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée const [stabilize, setStabilize] = useState(false); const [enableEndingEffect, setEnableEndingEffect] = useState(true); + const [showLogo, setShowLogo] = useState(true); // TTS Sync state const [syncInfo, setSyncInfo] = useState<{ @@ -280,6 +281,7 @@ export default function MobileNewReel() { ttsStyle, captionStyle, enableEndingEffect, + showLogo, }); }; @@ -484,6 +486,16 @@ export default function MobileNewReel() { +
+
p.id === selectedPages[0])?.pageName} endingEffect={enableEndingEffect} /> diff --git a/client/src/pages/mobile/remotion-video.tsx b/client/src/pages/mobile/remotion-video.tsx index b0e5e2c..d0a4a51 100644 --- a/client/src/pages/mobile/remotion-video.tsx +++ b/client/src/pages/mobile/remotion-video.tsx @@ -54,6 +54,7 @@ export default function MobileRemotionVideoPage() { const [publishDescription, setPublishDescription] = useState(""); const [isPublishing, setIsPublishing] = useState(false); const [captionStyle, setCaptionStyle] = useState(DEFAULT_CAPTION_STYLE); + const [showLogo, setShowLogo] = useState(true); const [voicePreview, setVoicePreview] = useState(null); const [renderProgress, setRenderProgress] = useState(0); const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null; @@ -169,6 +170,7 @@ export default function MobileRemotionVideoPage() { formData.append("ttsVoice", voiceSettings.voice); formData.append("ttsStyle", voiceSettings.style); formData.append("captionStyle", captionStyle); + formData.append("showLogo", String(showLogo)); 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)); } @@ -350,6 +352,10 @@ export default function MobileRemotionVideoPage() {
+
+ + +
@@ -474,6 +480,7 @@ export default function MobileRemotionVideoPage() { musicUrl={musicFileUrl ?? selectedTrack?.url} musicVolume={musicVolume} logoUrl={reelConfig?.logoUrl} + showWatermark={showLogo} storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName} endingEffect /> diff --git a/client/src/pages/new-reel.tsx b/client/src/pages/new-reel.tsx index ec5eb92..1ea3554 100644 --- a/client/src/pages/new-reel.tsx +++ b/client/src/pages/new-reel.tsx @@ -79,6 +79,7 @@ export default function NewReel() { // Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée const [stabilize, setStabilize] = useState(false); const [enableEndingEffect, setEnableEndingEffect] = useState(true); + const [showLogo, setShowLogo] = useState(true); // TTS Sync state const [syncInfo, setSyncInfo] = useState<{ @@ -423,6 +424,7 @@ export default function NewReel() { drawText, stabilize: stabilize, enableEndingEffect, + showLogo, }); }; @@ -818,6 +820,17 @@ export default function NewReel() {
)} +
+
+
p.id === selectedPages[0])?.pageName} endingEffect={enableEndingEffect} /> diff --git a/client/src/pages/remotion-video.tsx b/client/src/pages/remotion-video.tsx index dc555ba..a80b9cf 100644 --- a/client/src/pages/remotion-video.tsx +++ b/client/src/pages/remotion-video.tsx @@ -55,6 +55,7 @@ export default function RemotionVideoPage() { const [publishDescription, setPublishDescription] = useState(""); const [isPublishing, setIsPublishing] = useState(false); const [captionStyle, setCaptionStyle] = useState(DEFAULT_CAPTION_STYLE); + const [showLogo, setShowLogo] = useState(true); const [voicePreview, setVoicePreview] = useState(null); const [renderProgress, setRenderProgress] = useState(0); const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null; @@ -135,6 +136,7 @@ export default function RemotionVideoPage() { formData.append("ttsVoice", voiceSettings.voice); formData.append("ttsStyle", voiceSettings.style); formData.append("captionStyle", captionStyle); + formData.append("showLogo", String(showLogo)); 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)); } @@ -290,6 +292,10 @@ export default function RemotionVideoPage() {
+
+
@@ -420,6 +426,7 @@ export default function RemotionVideoPage() { musicUrl={musicFileUrl ?? selectedTrack?.url} musicVolume={musicVolume} logoUrl={reelConfig?.logoUrl} + showWatermark={showLogo} storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName} endingEffect /> diff --git a/client/src/remotion/ImageComposition.tsx b/client/src/remotion/ImageComposition.tsx index 015a1b0..4c4616f 100644 --- a/client/src/remotion/ImageComposition.tsx +++ b/client/src/remotion/ImageComposition.tsx @@ -14,6 +14,8 @@ export type ImageCompositionProps = { musicUrl?: string; musicVolume?: number; logoUrl?: string; + /** Petit logo affiché pendant la vidéo (le grand logo de fin reste sur la diapositive de fin). */ + showWatermark?: boolean; storeName?: string; /** Durée de la diapositive de fin (logo + nom du magasin), en secondes. */ endingSeconds?: number; @@ -78,7 +80,7 @@ const ImageSlide: React.FC<{ src: string; effectIndex: number }> = ({ src, effec /** Reel à partir d'images : diaporama animé, voix, sous-titres et diapositive de fin. */ export const ImageComposition: React.FC = ({ - images, words, captionStyle, audioUrl, musicUrl, musicVolume = 0.3, logoUrl, storeName, endingSeconds = 3, + images, words, captionStyle, audioUrl, musicUrl, musicVolume = 0.3, logoUrl, showWatermark = true, storeName, endingSeconds = 3, }) => { const { fps, durationInFrames } = useVideoConfig(); const hasEnding = Boolean(logoUrl || storeName) && endingSeconds > 0; @@ -113,7 +115,7 @@ export const ImageComposition: React.FC = ({ )} - {logoUrl && } + {logoUrl && showWatermark && } {hasEnding && } ); diff --git a/client/src/remotion/ReelVideo.tsx b/client/src/remotion/ReelVideo.tsx index 32c7d56..df884d3 100644 --- a/client/src/remotion/ReelVideo.tsx +++ b/client/src/remotion/ReelVideo.tsx @@ -12,6 +12,8 @@ export type ReelVideoProps = { words: TimedWord[]; captionStyle: CaptionStyle; logoUrl?: string; + /** Petit logo affiché pendant la vidéo (le grand logo de fin dépend de l'effet de fin). */ + showWatermark?: boolean; storeName?: string; logoStart?: number | null; fadeStart?: number | null; @@ -28,7 +30,7 @@ export type ReelVideoProps = { export const ReelVideo: React.FC = (props) => { const { fps } = useVideoConfig(); const { - videoUrl, videoDuration, totalDuration, words, captionStyle, logoUrl, storeName, + videoUrl, videoDuration, totalDuration, words, captionStyle, logoUrl, showWatermark = true, storeName, logoStart = null, fadeStart = null, mixedAudioUrl, voiceUrl, voiceDelay = 2, musicUrl, musicVolume = 0.25, } = props; @@ -67,7 +69,7 @@ export const ReelVideo: React.FC = (props) => { )} - {logoUrl && } + {logoUrl && showWatermark && } {logoStart != null && (logoUrl || storeName) && ( )} diff --git a/ffmpeg-service/app/api.py b/ffmpeg-service/app/api.py index 0aec2f1..1c57e5a 100644 --- a/ffmpeg-service/app/api.py +++ b/ffmpeg-service/app/api.py @@ -86,6 +86,8 @@ class ReelRequest(BaseModel): # Préparation Remotion : le logo est composé par Remotion, mais sa présence # allonge la vidéo pour laisser place à l'effet de fin. has_logo: bool = False + # Petit logo en coin pendant la vidéo (le grand logo de fin dépend de enable_ending_effect) + show_watermark: bool = True # Champs d'anciennes versions, acceptés et ignorés music_id: str | None = None word_duration: float | None = None @@ -296,6 +298,7 @@ async def _gather(request: ReelRequest, workdir: Path, clock: Stopwatch, *, fetc watermark=watermark if has_watermark else None, outro_expected=not fetch_logo and request.has_logo, ending_effect=request.enable_ending_effect, + show_watermark=request.show_watermark, keep_original_audio=info.has_audio, ) diff --git a/ffmpeg-service/app/render.py b/ffmpeg-service/app/render.py index 79d6a05..89298d3 100644 --- a/ffmpeg-service/app/render.py +++ b/ffmpeg-service/app/render.py @@ -47,6 +47,8 @@ class RenderPlan: # Effet de fin prévu sans que le logo passe par FFmpeg (rendu Remotion) outro_expected: bool = False ending_effect: bool = True + # Petit logo en coin pendant la vidéo (le grand logo de fin dépend de ending_effect) + show_watermark: bool = True keep_original_audio: bool = False @property @@ -175,17 +177,25 @@ def build_command(plan: RenderPlan) -> list[str]: current = "vbase" if wm_idx is not None: corner = "W-w-30:H-h-30" - if plan.outro and plan.ending_effect: - logo_start = plan.logo_start - graph.append(f"[{wm_idx}:v]scale=200:-1,split=2[wm_small][wm_big0]") - graph.append("[wm_big0]scale=-1:300[wm_big]") - graph.append(f"[{current}][wm_small]overlay={corner}:enable='lt(t,{logo_start:.3f})'[vwm1]") - graph.append(f"[vwm1][wm_big]overlay=(W-w)/2:(H-h)/2-100:enable='gte(t,{logo_start:.3f})'[vwm2]") - current = "vwm2" - else: + big_logo = bool(plan.outro and plan.ending_effect) + logo_start = plan.logo_start + if big_logo and plan.show_watermark: + graph.append(f"[{wm_idx}:v]split=2[wm_small0][wm_big0]") + graph.append("[wm_small0]scale=200:-1[wm_small]") + elif plan.show_watermark: graph.append(f"[{wm_idx}:v]scale=200:-1[wm_small]") - graph.append(f"[{current}][wm_small]overlay={corner}[vwm1]") + elif big_logo: + graph.append(f"[{wm_idx}:v]null[wm_big0]") + if plan.show_watermark: + until = f":enable='lt(t,{logo_start:.3f})'" if big_logo else "" + graph.append(f"[{current}][wm_small]overlay={corner}{until}[vwm1]") current = "vwm1" + if big_logo: + graph.append("[wm_big0]scale=-1:300[wm_big]") + graph.append( + f"[{current}][wm_big]overlay=(W-w)/2:(H-h)/2-100:enable='gte(t,{logo_start:.3f})'[vwm2]" + ) + current = "vwm2" if plan.outro and plan.ending_effect: graph.append(f"[{current}]subtitles='{filter_path(plan.outro)}'[vout0]") current = "vout0" diff --git a/ffmpeg-service/tests/test_render.py b/ffmpeg-service/tests/test_render.py index 26b1911..56789fb 100644 --- a/ffmpeg-service/tests/test_render.py +++ b/ffmpeg-service/tests/test_render.py @@ -77,6 +77,16 @@ def test_big_logo_waits_for_the_end_of_the_voice(): assert "gte(t,6.800)" in _graph(build_command(plan)) +def test_corner_logo_can_be_hidden_while_keeping_the_ending_logo(): + base = dict(video=Path("in.mp4"), video_duration=6.0, output=Path("out.mp4"), watermark=Path("logo.png")) + hidden = _graph(build_command(RenderPlan(**base, outro=Path("o.ass"), show_watermark=False))) + assert "W-w-30:H-h-30" not in hidden + assert "overlay=(W-w)/2" in hidden + + nothing = _graph(build_command(RenderPlan(**base, show_watermark=False))) + assert "overlay" not in nothing + + def test_prepared_video_has_no_audio_and_final_duration(): from app.render import build_prepared_video_command diff --git a/server/routes/remotion.ts b/server/routes/remotion.ts index 3187907..2be53f7 100644 --- a/server/routes/remotion.ts +++ b/server/routes/remotion.ts @@ -75,6 +75,7 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { ttsVoice: req.body.ttsVoice, ttsStyle: req.body.ttsStyle || undefined, captionStyle: req.body.captionStyle || undefined, + showLogo: req.body.showLogo !== "false", storeName: await resolveStoreName(user.id, req.body.selectedPageId), tempFiles: uploaded.map((f) => f.path), }); diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts index 726248c..f007458 100644 --- a/server/services/ffmpeg.ts +++ b/server/services/ffmpeg.ts @@ -36,6 +36,8 @@ export interface ReelRenderOptions { watermarkUrl?: string; storeName?: string; enableEndingEffect?: boolean; + /** Petit logo pendant la vidéo (le grand logo de fin dépend de enableEndingEffect). */ + showLogo?: boolean; } export interface ReelRenderResult { @@ -143,6 +145,7 @@ export class FFmpegService { watermark_url: options.watermarkUrl, store_name: options.storeName, enable_ending_effect: options.enableEndingEffect ?? true, + show_watermark: options.showLogo ?? true, }; console.log('🎬 Rendu du Reel :', { diff --git a/server/services/reels/imagesPipeline.ts b/server/services/reels/imagesPipeline.ts index 3c1bf0f..a38bc61 100644 --- a/server/services/reels/imagesPipeline.ts +++ b/server/services/reels/imagesPipeline.ts @@ -75,6 +75,7 @@ export async function runImagesReelJob({ job, progress }: JobContext): Promise { await progress(15, "render"); + // Le logo sert au coin de la vidéo et/ou à l'effet de fin : inutile de l'envoyer si aucun des deux + const logoUsed = Boolean(logoPath) && (options.showLogo !== false || options.enableEndingEffect !== false); const rendered = await ffmpegService.renderReel(videoUrl, { ...options, - watermarkUrl: logoPath ? resolveInternalUrl(logoPath) : undefined, + watermarkUrl: logoUsed && logoPath ? resolveInternalUrl(logoPath) : undefined, }); return rendered.video; } @@ -133,6 +136,7 @@ async function renderWithRemotion( words: prepared.words, captionStyle: params.captionStyle, logoUrl: logoPath ? await toDataUrl(logoPath) : undefined, + showWatermark: params.showLogo, storeName: params.enableEndingEffect ? params.storeName : undefined, logoStart: prepared.logoStart, fadeStart: params.enableEndingEffect ? Math.max(0, prepared.totalDuration - FADE_SECONDS) : null, diff --git a/shared/reel.test.ts b/shared/reel.test.ts index f68500c..01b6c4e 100644 --- a/shared/reel.test.ts +++ b/shared/reel.test.ts @@ -10,7 +10,7 @@ describe("videoReelParamsSchema", () => { it("applique les valeurs par défaut", () => { const params = videoReelParamsSchema.parse({ videoMediaId: "v", pageIds: ["p"] }); - expect(params).toMatchObject({ captionStyle: "bold", stabilize: false, drawText: true, musicVolume: 0.25 }); + expect(params).toMatchObject({ captionStyle: "bold", stabilize: false, drawText: true, musicVolume: 0.25, showLogo: true }); }); it("accepte les anciens paramètres encore stockés dans des jobs", () => { diff --git a/shared/reel.ts b/shared/reel.ts index 62cba2d..9e6e34b 100644 --- a/shared/reel.ts +++ b/shared/reel.ts @@ -39,6 +39,8 @@ export const videoReelParamsSchema = z.object({ drawText: z.boolean().default(true), captionStyle: z.enum(CAPTION_STYLES).default(DEFAULT_CAPTION_STYLE), stabilize: z.boolean().default(false), + // Petit logo pendant la vidéo ; le grand logo de fin dépend de enableEndingEffect + showLogo: z.boolean().default(true), enableEndingEffect: z.boolean().default(true), // Déterminé par le serveur à partir de la première page, jamais par le client storeName: z.string().optional(), @@ -58,6 +60,7 @@ export const imagesReelParamsSchema = z.object({ ttsVoice: optionalText, ttsStyle: z.enum(TTS_STYLES).optional(), captionStyle: z.enum(CAPTION_STYLES).default(DEFAULT_CAPTION_STYLE), + showLogo: z.boolean().default(true), storeName: z.string().optional(), // Fichiers temporaires à supprimer une fois le rendu terminé tempFiles: z.array(z.string()).default([]),