From 1d47763b29ff7786ed260fa922d12e0fa6da9481 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:45:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(reels):=20interrupteur=20=C2=AB=20Afficher?= =?UTF-8?q?=20le=20logo=20sur=20la=20vid=C3=A9o=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le petit logo affiché pendant la vidéo devient optionnel (activé par défaut), sur les 4 pages Reel (vidéo et images, bureau et mobile) ainsi que dans l'aperçu. Le grand logo de l'effet de fin reste piloté par son propre interrupteur. Pris en charge par le rendu Remotion et par le rendu FFmpeg de secours (show_watermark). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4 --- client/src/components/reels/reel-preview.tsx | 9 +++++-- client/src/pages/mobile/new-reel.tsx | 13 +++++++++ client/src/pages/mobile/remotion-video.tsx | 7 +++++ client/src/pages/new-reel.tsx | 14 ++++++++++ client/src/pages/remotion-video.tsx | 7 +++++ client/src/remotion/ImageComposition.tsx | 6 +++-- client/src/remotion/ReelVideo.tsx | 6 +++-- ffmpeg-service/app/api.py | 3 +++ ffmpeg-service/app/render.py | 28 +++++++++++++------- ffmpeg-service/tests/test_render.py | 10 +++++++ server/routes/remotion.ts | 1 + server/services/ffmpeg.ts | 3 +++ server/services/reels/imagesPipeline.ts | 1 + server/services/reels/videoPipeline.ts | 6 ++++- shared/reel.test.ts | 2 +- shared/reel.ts | 3 +++ 16 files changed, 102 insertions(+), 17 deletions(-) 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([]),