mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-24 15:38: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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user