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
No files matched your search
@@ -124,3 +124,13 @@ GEMINI_API_KEY=
|
|||||||
# (tiny, base, small). Choisi à la construction de l'image : changer la valeur
|
# (tiny, base, small). Choisi à la construction de l'image : changer la valeur
|
||||||
# demande de reconstruire ffmpeg-api.
|
# demande de reconstruire ffmpeg-api.
|
||||||
# WHISPER_MODEL=base
|
# WHISPER_MODEL=base
|
||||||
|
#
|
||||||
|
# Moteur de rendu des Reels vidéo :
|
||||||
|
# - remotion (défaut) : sous-titres animés, logo et effet de fin identiques à
|
||||||
|
# l'aperçu affiché dans l'application ; ~30 à 60 s pour un Reel de 30 s ;
|
||||||
|
# - ffmpeg : plus rapide (~10 s), sous-titres karaoké classiques.
|
||||||
|
# REEL_RENDERER=remotion
|
||||||
|
#
|
||||||
|
# Onglets Chromium utilisés en parallèle par Remotion (défaut : moitié des cœurs).
|
||||||
|
# Réduisez-le si le serveur manque de mémoire.
|
||||||
|
# RENDER_CONCURRENCY=
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
app:
|
||||||
|
name: Application (TypeScript)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
# Les dépendances natives (canvas, sharp) et le navigateur Remotion ne
|
||||||
|
# servent pas aux vérifications : on saute leurs scripts d'installation.
|
||||||
|
- run: npm ci --ignore-scripts
|
||||||
|
- run: npm run check
|
||||||
|
- run: npm test
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
ffmpeg-service:
|
||||||
|
name: Service vidéo (Python)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ffmpeg-service
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: ffmpeg-service/requirements*.txt
|
||||||
|
- run: pip install -r requirements-dev.txt
|
||||||
|
- run: ruff check .
|
||||||
|
- run: ruff format --check .
|
||||||
|
- run: pytest -q
|
||||||
@@ -182,6 +182,10 @@ La stack se construit depuis les sources : `app` et `ffmpeg-api` ont une section
|
|||||||
- **Voix Gemini** : renseignez `GEMINI_API_KEY` (ou la clé dans l'application).
|
- **Voix Gemini** : renseignez `GEMINI_API_KEY` (ou la clé dans l'application).
|
||||||
Sans clé, la voix Edge gratuite est utilisée et un avertissement apparaît
|
Sans clé, la voix Edge gratuite est utilisée et un avertissement apparaît
|
||||||
dans les logs.
|
dans les logs.
|
||||||
|
- **Moteur de rendu** : par défaut, les Reels vidéo sont montés par Remotion
|
||||||
|
(sous-titres animés, identiques à l'aperçu de l'application). En cas de
|
||||||
|
serveur trop lent, `REEL_RENDERER=ffmpeg` revient au rendu FFmpeg, plus
|
||||||
|
rapide. `RENDER_CONCURRENCY` limite le nombre d'onglets Chromium.
|
||||||
- **Vérifier la version déployée** : au démarrage, les logs de `socialflow-app`
|
- **Vérifier la version déployée** : au démarrage, les logs de `socialflow-app`
|
||||||
affichent `[ReelQueue] Worker démarré`, et `socialflow-ffmpeg` répond
|
affichent `[ReelQueue] Worker démarré`, et `socialflow-ffmpeg` répond
|
||||||
`{"status":"ok","version":2}` sur `/health`.
|
`{"status":"ok","version":2}` sur `/health`.
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ RUN rm -rf client/node_modules \
|
|||||||
/tmp/remotion-*
|
/tmp/remotion-*
|
||||||
|
|
||||||
# Pré-bundler la composition Remotion
|
# Pré-bundler la composition Remotion
|
||||||
RUN node scripts/prebundle-remotion.js || true
|
RUN node scripts/prebundle-remotion.mjs || true
|
||||||
|
|
||||||
# Télécharger le Chrome headless shell de Remotion (binaire glibc natif sur Debian)
|
# Télécharger le Chrome headless shell de Remotion (binaire glibc natif sur Debian)
|
||||||
RUN npx remotion browser ensure && echo "✓ Remotion browser prêt"
|
RUN npx remotion browser ensure && echo "✓ Remotion browser prêt"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 125 KiB |
@@ -4,16 +4,20 @@ import { Progress } from "@/components/ui/progress";
|
|||||||
import { Clapperboard, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
import { Clapperboard, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||||
import type { Post } from "@shared/schema";
|
import type { Post } from "@shared/schema";
|
||||||
|
|
||||||
/** Labels for each progress stage */
|
type OngoingReel = Post & { generationStep?: string | null };
|
||||||
function getStageLabel(progress: number, status?: string): string {
|
|
||||||
if (status === 'pending') return "En attente de traitement...";
|
/** Libellé de l'étape en cours, telle que rapportée par la file de rendu. */
|
||||||
if (progress <= 5) return "Démarrage du traitement…";
|
const STEP_LABELS: Record<string, string> = {
|
||||||
if (progress <= 15) return "Traitement vidéo (FFmpeg)…";
|
prepare: "Préparation…",
|
||||||
if (progress <= 65) return "Encodage en cours…";
|
voice: "Voix et préparation de la vidéo…",
|
||||||
if (progress <= 85) return "Upload vers le cloud…";
|
render: "Montage : sous-titres, logo, effets…",
|
||||||
if (progress <= 90) return "Liaison du média…";
|
store: "Enregistrement dans la médiathèque…",
|
||||||
if (progress <= 95) return "Publication en cours…";
|
publish: "Publication…",
|
||||||
return "Finalisation…";
|
};
|
||||||
|
|
||||||
|
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 {
|
interface OngoingReelsProps {
|
||||||
@@ -22,12 +26,13 @@ interface OngoingReelsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||||
const { data: ongoingPosts = [] } = useQuery<Post[]>({
|
const { data: ongoingPosts = [] } = useQuery<OngoingReel[]>({
|
||||||
queryKey: ["/api/reels/ongoing"],
|
queryKey: ["/api/reels/ongoing"],
|
||||||
// Cette route ne renvoie que les générations en cours : tant qu'elle est
|
// 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
|
// 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.
|
// 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)
|
// 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" : ""}>
|
<CardHeader className={compact ? "pb-2 px-4 pt-4" : ""}>
|
||||||
<CardTitle className={`flex items-center gap-2 ${compact ? "text-base" : ""}`}>
|
<CardTitle className={`flex items-center gap-2 ${compact ? "text-base" : ""}`}>
|
||||||
|
{ongoingPosts.some((p) => p.generationStatus !== "failed") ? (
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||||
Génération en cours
|
) : (
|
||||||
|
<XCircle className="w-5 h-5 text-destructive" />
|
||||||
|
)}
|
||||||
|
Reels en cours
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className={compact ? "px-4 pb-4 pt-0" : ""}>
|
<CardContent className={compact ? "px-4 pb-4 pt-0" : ""}>
|
||||||
@@ -73,8 +82,8 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
|||||||
{isFailed ? (
|
{isFailed ? (
|
||||||
<div className="flex items-center gap-1.5 mt-2 text-destructive">
|
<div className="flex items-center gap-1.5 mt-2 text-destructive">
|
||||||
<XCircle className="w-4 h-4" />
|
<XCircle className="w-4 h-4" />
|
||||||
<span className="text-xs">
|
<span className="text-xs break-words">
|
||||||
{post.generationError || "Erreur lors du traitement"}
|
Échec : {post.generationError || "erreur lors du traitement"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -87,7 +96,7 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mt-1.5">
|
<div className="flex items-center justify-between mt-1.5">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{getStageLabel(progress)}
|
{getStageLabel(post)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-medium text-primary">
|
<span className="text-xs font-medium text-primary">
|
||||||
{progress}%
|
{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 TtsEngine,
|
||||||
type TtsStyle,
|
type TtsStyle,
|
||||||
} from "@shared/voices";
|
} from "@shared/voices";
|
||||||
|
import type { TimedWord } from "@shared/captions";
|
||||||
|
|
||||||
export interface VoiceSettings {
|
export interface VoiceSettings {
|
||||||
engine: TtsEngine;
|
engine: TtsEngine;
|
||||||
@@ -27,12 +28,38 @@ export interface VoiceSettings {
|
|||||||
style: TtsStyle;
|
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 {
|
interface VoicePickerProps {
|
||||||
value: VoiceSettings;
|
value: VoiceSettings;
|
||||||
onChange: (value: VoiceSettings) => void;
|
onChange: (value: VoiceSettings) => void;
|
||||||
/** Texte lu par le bouton « Tester la voix ». */
|
/** Texte lu par le bouton « Tester la voix ». */
|
||||||
sampleText?: string;
|
sampleText?: string;
|
||||||
compact?: boolean;
|
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 !";
|
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.
|
* 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).
|
* 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 { toast } = useToast();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
@@ -68,8 +95,9 @@ export function VoicePicker({ value, onChange, sampleText, compact = false }: Vo
|
|||||||
if (playing) return stop();
|
if (playing) return stop();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const text = sampleText?.trim() || FALLBACK_SAMPLE;
|
||||||
const response = await apiRequest("POST", "/api/reels/tts-preview", {
|
const response = await apiRequest("POST", "/api/reels/tts-preview", {
|
||||||
text: sampleText?.trim() || FALLBACK_SAMPLE,
|
text,
|
||||||
ttsEngine: value.engine,
|
ttsEngine: value.engine,
|
||||||
ttsVoice: value.voice,
|
ttsVoice: value.voice,
|
||||||
ttsStyle: value.style,
|
ttsStyle: value.style,
|
||||||
@@ -78,7 +106,11 @@ export function VoicePicker({ value, onChange, sampleText, compact = false }: Vo
|
|||||||
for (const warning of data.warnings ?? []) {
|
for (const warning of data.warnings ?? []) {
|
||||||
toast({ title: "Voix de secours utilisée", description: warning });
|
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?.pause();
|
||||||
audioRef.current = audio;
|
audioRef.current = audio;
|
||||||
audio.onended = () => setPlaying(false);
|
audio.onended = () => setPlaying(false);
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
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 { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
||||||
import type { SocialPage, Media } from "@shared/schema";
|
import type { SocialPage, Media } from "@shared/schema";
|
||||||
@@ -67,6 +70,10 @@ export default function MobileNewReel() {
|
|||||||
style: DEFAULT_TTS_STYLE,
|
style: DEFAULT_TTS_STYLE,
|
||||||
});
|
});
|
||||||
const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings;
|
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
|
// État audio preview
|
||||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||||
@@ -203,8 +210,10 @@ export default function MobileNewReel() {
|
|||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
||||||
toast({
|
toast({
|
||||||
title: data.success ? "Reel créé !" : "Attention",
|
title: "Création du Reel lancée",
|
||||||
description: data.success ? "Publication réussie" : "Certaines erreurs",
|
description: data.queued
|
||||||
|
? "Un autre Reel est en cours : le vôtre démarrera juste après."
|
||||||
|
: "Suivez l'avancement sur l'accueil.",
|
||||||
});
|
});
|
||||||
navigate('/');
|
navigate('/');
|
||||||
},
|
},
|
||||||
@@ -264,6 +273,7 @@ export default function MobileNewReel() {
|
|||||||
ttsEngine,
|
ttsEngine,
|
||||||
ttsVoice,
|
ttsVoice,
|
||||||
ttsStyle,
|
ttsStyle,
|
||||||
|
captionStyle,
|
||||||
enableEndingEffect,
|
enableEndingEffect,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -456,6 +466,10 @@ export default function MobileNewReel() {
|
|||||||
className="text-lg"
|
className="text-lg"
|
||||||
rows={3}
|
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">
|
<div className="flex items-center space-x-2 mt-4">
|
||||||
<Switch
|
<Switch
|
||||||
id="enable-ending-effect"
|
id="enable-ending-effect"
|
||||||
@@ -490,6 +504,7 @@ export default function MobileNewReel() {
|
|||||||
value={voiceSettings}
|
value={voiceSettings}
|
||||||
onChange={setVoiceSettings}
|
onChange={setVoiceSettings}
|
||||||
sampleText={overlayText}
|
sampleText={overlayText}
|
||||||
|
onPreview={setVoicePreview}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -524,6 +539,31 @@ export default function MobileNewReel() {
|
|||||||
{/* PUBLISH STEP */}
|
{/* PUBLISH STEP */}
|
||||||
{currentStep === 'publish' && (
|
{currentStep === 'publish' && (
|
||||||
<div className="space-y-6">
|
<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>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base">Diffusion</CardTitle>
|
<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 { 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 { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -50,6 +53,11 @@ export default function MobileRemotionVideoPage() {
|
|||||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||||
const [publishDescription, setPublishDescription] = useState("");
|
const [publishDescription, setPublishDescription] = useState("");
|
||||||
const [isPublishing, setIsPublishing] = useState(false);
|
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 audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||||
const galleryInputRef = 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: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
||||||
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
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 facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||||
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||||
|
|
||||||
@@ -142,6 +157,7 @@ export default function MobileRemotionVideoPage() {
|
|||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; }
|
if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; }
|
||||||
setIsRendering(true);
|
setIsRendering(true);
|
||||||
|
setRenderProgress(0);
|
||||||
setVideoUrl(null);
|
setVideoUrl(null);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -152,6 +168,7 @@ export default function MobileRemotionVideoPage() {
|
|||||||
formData.append("ttsEngine", voiceSettings.engine);
|
formData.append("ttsEngine", voiceSettings.engine);
|
||||||
formData.append("ttsVoice", voiceSettings.voice);
|
formData.append("ttsVoice", voiceSettings.voice);
|
||||||
formData.append("ttsStyle", voiceSettings.style);
|
formData.append("ttsStyle", voiceSettings.style);
|
||||||
|
formData.append("captionStyle", captionStyle);
|
||||||
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
|
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
|
||||||
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
|
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
|
||||||
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); 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" });
|
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
|
||||||
setIsRendering(false);
|
setIsRendering(false);
|
||||||
} else {
|
} else {
|
||||||
|
setRenderProgress(job.progress ?? 0);
|
||||||
setTimeout(checkStatus, 3000);
|
setTimeout(checkStatus, 3000);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -328,6 +346,10 @@ export default function MobileRemotionVideoPage() {
|
|||||||
)}
|
)}
|
||||||
<Textarea placeholder="Texte overlay…" value={overlayText}
|
<Textarea placeholder="Texte overlay…" value={overlayText}
|
||||||
onChange={e => setOverlayText(e.target.value)} rows={2} className="text-sm" />
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<Switch id="tts-m" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
|
<Switch id="tts-m" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
|
||||||
<Label htmlFor="tts-m" className="text-sm">Voix TTS</Label>
|
<Label htmlFor="tts-m" className="text-sm">Voix TTS</Label>
|
||||||
@@ -336,7 +358,7 @@ export default function MobileRemotionVideoPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground">TTS — voix activée</p>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -433,10 +455,37 @@ export default function MobileRemotionVideoPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Générer */}
|
||||||
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12" size="lg">
|
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12" size="lg">
|
||||||
{isRendering
|
{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</>}
|
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
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 { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||||
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||||
import type { SocialPage, Media } from "@shared/schema";
|
import type { SocialPage, Media } from "@shared/schema";
|
||||||
@@ -89,6 +92,11 @@ export default function NewReel() {
|
|||||||
style: DEFAULT_TTS_STYLE,
|
style: DEFAULT_TTS_STYLE,
|
||||||
});
|
});
|
||||||
const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings;
|
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
|
// Enable TTS by default on mobile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -245,10 +253,10 @@ export default function NewReel() {
|
|||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
||||||
toast({
|
toast({
|
||||||
title: data.success ? "Reel créé !" : "Reel créé avec avertissements",
|
title: "Création du Reel lancée",
|
||||||
description: data.success
|
description: data.queued
|
||||||
? "Votre Reel a été publié avec succès"
|
? "Un autre Reel est en cours : le vôtre démarrera juste après. Suivez l'avancement sur le tableau de bord."
|
||||||
: "Certaines pages ont échoué",
|
: "Suivez l'avancement sur le tableau de bord.",
|
||||||
});
|
});
|
||||||
navigate('/');
|
navigate('/');
|
||||||
},
|
},
|
||||||
@@ -406,6 +414,7 @@ export default function NewReel() {
|
|||||||
ttsEngine,
|
ttsEngine,
|
||||||
ttsVoice,
|
ttsVoice,
|
||||||
ttsStyle,
|
ttsStyle,
|
||||||
|
captionStyle,
|
||||||
drawText,
|
drawText,
|
||||||
stabilize: stabilize,
|
stabilize: stabilize,
|
||||||
enableEndingEffect,
|
enableEndingEffect,
|
||||||
@@ -783,6 +792,15 @@ export default function NewReel() {
|
|||||||
</Label>
|
</Label>
|
||||||
</div>
|
</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">
|
<div className="flex items-center space-x-2 mt-4">
|
||||||
<Switch
|
<Switch
|
||||||
id="enable-ending-effect"
|
id="enable-ending-effect"
|
||||||
@@ -817,6 +835,7 @@ export default function NewReel() {
|
|||||||
value={voiceSettings}
|
value={voiceSettings}
|
||||||
onChange={setVoiceSettings}
|
onChange={setVoiceSettings}
|
||||||
sampleText={overlayText}
|
sampleText={overlayText}
|
||||||
|
onPreview={setVoicePreview}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -962,31 +981,20 @@ export default function NewReel() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{selectedVideo ? (
|
{selectedVideo ? (
|
||||||
<div className="relative aspect-[9/16] bg-black rounded-lg overflow-hidden">
|
<ReelPreview
|
||||||
<video
|
kind="video"
|
||||||
src={selectedVideo.originalUrl}
|
videoUrl={selectedVideo.originalUrl}
|
||||||
className="w-full h-full object-cover"
|
text={overlayText}
|
||||||
muted
|
showCaptions={drawText}
|
||||||
playsInline
|
captionStyle={captionStyle}
|
||||||
loop
|
ttsEnabled={ttsEnabled}
|
||||||
autoPlay
|
voice={currentVoice}
|
||||||
|
musicUrl={selectedTrack?.previewUrl}
|
||||||
|
musicVolume={musicVolume[0] / 100}
|
||||||
|
logoUrl={reelConfig?.logoUrl}
|
||||||
|
storeName={pages.find((p) => p.id === selectedPages[0])?.pageName}
|
||||||
|
endingEffect={enableEndingEffect}
|
||||||
/>
|
/>
|
||||||
{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>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="aspect-[9/16] bg-muted rounded-lg flex items-center justify-center">
|
<div className="aspect-[9/16] bg-muted rounded-lg flex items-center justify-center">
|
||||||
<div className="text-center text-muted-foreground">
|
<div className="text-center text-muted-foreground">
|
||||||
@@ -1026,115 +1034,6 @@ export default function NewReel() {
|
|||||||
</div >
|
</div >
|
||||||
</div >
|
</div >
|
||||||
</main >
|
</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 >
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useEffect, useMemo, useState, useRef } from "react";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import TopBar from "@/components/topbar";
|
import TopBar from "@/components/topbar";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
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 { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -51,6 +54,11 @@ export default function RemotionVideoPage() {
|
|||||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||||
const [publishDescription, setPublishDescription] = useState("");
|
const [publishDescription, setPublishDescription] = useState("");
|
||||||
const [isPublishing, setIsPublishing] = useState(false);
|
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 audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -58,6 +66,13 @@ export default function RemotionVideoPage() {
|
|||||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
||||||
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
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 facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||||
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||||
|
|
||||||
@@ -108,6 +123,7 @@ export default function RemotionVideoPage() {
|
|||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (totalSelected < 3) { toast({ title: "Minimum 3 images requises.", variant: "destructive" }); return; }
|
if (totalSelected < 3) { toast({ title: "Minimum 3 images requises.", variant: "destructive" }); return; }
|
||||||
setIsRendering(true);
|
setIsRendering(true);
|
||||||
|
setRenderProgress(0);
|
||||||
setVideoUrl(null);
|
setVideoUrl(null);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -118,6 +134,7 @@ export default function RemotionVideoPage() {
|
|||||||
formData.append("ttsEngine", voiceSettings.engine);
|
formData.append("ttsEngine", voiceSettings.engine);
|
||||||
formData.append("ttsVoice", voiceSettings.voice);
|
formData.append("ttsVoice", voiceSettings.voice);
|
||||||
formData.append("ttsStyle", voiceSettings.style);
|
formData.append("ttsStyle", voiceSettings.style);
|
||||||
|
formData.append("captionStyle", captionStyle);
|
||||||
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
|
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
|
||||||
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
|
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
|
||||||
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); 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" });
|
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
|
||||||
setIsRendering(false);
|
setIsRendering(false);
|
||||||
} else {
|
} else {
|
||||||
|
setRenderProgress(job.progress ?? 0);
|
||||||
setTimeout(checkStatus, 3000);
|
setTimeout(checkStatus, 3000);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -268,6 +286,10 @@ export default function RemotionVideoPage() {
|
|||||||
)}
|
)}
|
||||||
<Textarea placeholder="Votre texte overlay..." value={overlayText}
|
<Textarea placeholder="Votre texte overlay..." value={overlayText}
|
||||||
onChange={e => setOverlayText(e.target.value)} rows={3} />
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<Switch id="tts" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
|
<Switch id="tts" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
|
||||||
<Label htmlFor="tts">Activer la voix TTS</Label>
|
<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">
|
<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>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -379,10 +401,37 @@ export default function RemotionVideoPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Générer */}
|
||||||
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full" size="lg">
|
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full" size="lg">
|
||||||
{isRendering
|
{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</>}
|
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo</>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import { AbsoluteFill, Img, Sequence, useVideoConfig, useCurrentFrame, Html5Audio, spring, interpolate } from "remotion";
|
import React from "react";
|
||||||
|
import { AbsoluteFill, Html5Audio, Img, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
export type WordTiming = {
|
import type { CaptionStyle, TimedWord } from "@shared/captions";
|
||||||
word: string;
|
import { Captions } from "./components/Captions";
|
||||||
startFrame: number;
|
import { Outro, Watermark } from "./components/Branding";
|
||||||
endFrame: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ImageCompositionProps = {
|
export type ImageCompositionProps = {
|
||||||
images: string[];
|
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;
|
audioUrl?: string;
|
||||||
wordTimings?: WordTiming[];
|
|
||||||
musicUrl?: string;
|
musicUrl?: string;
|
||||||
musicVolume?: number;
|
musicVolume?: number;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
storeName?: 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
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Reel à partir d'images : diaporama animé, voix, sous-titres et diapositive de fin. */
|
||||||
* CaptionGroup — renders a group of words (CapCut-style).
|
export const ImageComposition: React.FC<ImageCompositionProps> = ({
|
||||||
* Active word is highlighted in yellow with glow; others are white.
|
images, words, captionStyle, audioUrl, musicUrl, musicVolume = 0.3, logoUrl, storeName, endingSeconds = 3,
|
||||||
* 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) => {
|
|
||||||
const { fps, durationInFrames } = useVideoConfig();
|
const { fps, durationInFrames } = useVideoConfig();
|
||||||
const frame = useCurrentFrame();
|
const hasEnding = Boolean(logoUrl || storeName) && endingSeconds > 0;
|
||||||
|
const endingFrames = hasEnding ? Math.round(endingSeconds * fps) : 0;
|
||||||
const hasEnding = !!(logoUrl || storeName);
|
const contentFrames = durationInFrames - endingFrames;
|
||||||
const effectiveEndingFrames = hasEnding ? endingFrames : 0;
|
const perImage = Math.floor(contentFrames / Math.max(images.length, 1));
|
||||||
const contentFrames = durationInFrames - effectiveEndingFrames;
|
const endingStart = contentFrames / fps;
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||||
{/* Images — displayed immediately, no entrance transition */}
|
|
||||||
{images.map((imgUrl, index) => (
|
{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} />
|
<ImageSlide src={imgUrl} effectIndex={index} />
|
||||||
</Sequence>
|
</Sequence>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* TTS Audio */}
|
|
||||||
{audioUrl && <Html5Audio src={audioUrl} />}
|
{audioUrl && <Html5Audio src={audioUrl} />}
|
||||||
|
{musicUrl && (
|
||||||
{/* Background music */}
|
<Html5Audio
|
||||||
{musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />}
|
src={musicUrl}
|
||||||
|
loop
|
||||||
{/* Caption overlay — CapCut style */}
|
volume={(frame) =>
|
||||||
{renderCaption && (
|
(words.some((w) => frame / fps >= w.start - 0.2 && frame / fps <= w.end + 0.3)
|
||||||
<AbsoluteFill
|
? musicVolume * 0.35
|
||||||
style={{
|
: musicVolume) *
|
||||||
justifyContent: "flex-end",
|
interpolate(frame, [durationInFrames - fps, durationInFrames], [1, 0], {
|
||||||
alignItems: "center",
|
extrapolateLeft: "clamp",
|
||||||
paddingBottom: 180,
|
extrapolateRight: "clamp",
|
||||||
}}
|
})
|
||||||
>
|
}
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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
|
<Captions words={words} style={captionStyle} hideAfter={hasEnding ? endingStart : null} />
|
||||||
style={{
|
{logoUrl && <Watermark logoUrl={logoUrl} until={hasEnding ? endingStart : null} />}
|
||||||
color: "white",
|
{hasEnding && <Outro start={endingStart} logoUrl={logoUrl} storeName={storeName} opaque />}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</AbsoluteFill>
|
</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 { Composition, type CalculateMetadataFunction } from "remotion";
|
||||||
import { ImageComposition, ImageCompositionProps } from "./ImageComposition";
|
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 = () => {
|
export const RemotionRoot: React.FC = () => {
|
||||||
return (
|
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
|
<Composition
|
||||||
id="ImageVideo"
|
id="ImageVideo"
|
||||||
component={ImageComposition}
|
component={ImageComposition}
|
||||||
durationInFrames={360}
|
fps={REEL_FPS}
|
||||||
fps={30}
|
width={REEL_WIDTH}
|
||||||
width={1080}
|
height={REEL_HEIGHT}
|
||||||
height={1920}
|
durationInFrames={REEL_FPS * 25}
|
||||||
|
calculateMetadata={byTotalDuration<ImageCompositionProps>()}
|
||||||
defaultProps={{
|
defaultProps={{
|
||||||
images: [
|
images: ["https://placehold.co/1080x1920?text=Image+1"],
|
||||||
"https://placehold.co/1080x1920?text=Image+1",
|
totalDuration: 25,
|
||||||
"https://placehold.co/1080x1920?text=Image+2",
|
words: [],
|
||||||
"https://placehold.co/1080x1920?text=Image+3"
|
captionStyle: DEFAULT_CAPTION_STYLE,
|
||||||
],
|
|
||||||
} as ImageCompositionProps}
|
} 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";
|
||||||
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 168 KiB |
@@ -52,6 +52,11 @@ services:
|
|||||||
# Configuration FFmpeg Service
|
# Configuration FFmpeg Service
|
||||||
FFMPEG_API_URL: http://ffmpeg-api:8000
|
FFMPEG_API_URL: http://ffmpeg-api:8000
|
||||||
FFMPEG_API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
FFMPEG_API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||||
|
# Rendu des Reels vidéo : remotion (sous-titres animés, identique à
|
||||||
|
# l'aperçu) ou ffmpeg (plus rapide, sous-titres classiques)
|
||||||
|
REEL_RENDERER: ${REEL_RENDERER:-remotion}
|
||||||
|
# Onglets Chromium en parallèle pour Remotion (défaut : moitié des cœurs)
|
||||||
|
RENDER_CONCURRENCY: ${RENDER_CONCURRENCY:-}
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
- nginx_default
|
- nginx_default
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@@ -74,6 +74,9 @@ class ReelRequest(BaseModel):
|
|||||||
draw_text: bool = True
|
draw_text: bool = True
|
||||||
stabilize: bool = False
|
stabilize: bool = False
|
||||||
enable_ending_effect: bool = True
|
enable_ending_effect: bool = True
|
||||||
|
# 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
|
||||||
# Champs d'anciennes versions, acceptés et ignorés
|
# Champs d'anciennes versions, acceptés et ignorés
|
||||||
music_id: str | None = None
|
music_id: str | None = None
|
||||||
word_duration: float | None = None
|
word_duration: float | None = None
|
||||||
@@ -115,13 +118,23 @@ async def preview_tts(request: TtsRequest):
|
|||||||
|
|
||||||
@app.post("/process-reel", dependencies=[Depends(require_key)])
|
@app.post("/process-reel", dependencies=[Depends(require_key)])
|
||||||
async def process_reel(request: ReelRequest):
|
async def process_reel(request: ReelRequest):
|
||||||
"""Produit le Reel ; le MP4 se récupère ensuite via GET /files/{job_id}/output.mp4."""
|
"""Rendu complet par FFmpeg ; le MP4 se récupère via GET /files/{job_id}/output.mp4."""
|
||||||
|
return await _guarded(_process, request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/prepare-reel", dependencies=[Depends(require_key)])
|
||||||
|
async def prepare_reel(request: ReelRequest):
|
||||||
|
"""Prépare un rendu Remotion : image recadrée à la durée finale, piste son
|
||||||
|
finale et minutage des mots. Rien n'est incrusté dans l'image."""
|
||||||
|
return await _guarded(_prepare, request)
|
||||||
|
|
||||||
|
|
||||||
|
async def _guarded(handler, request: ReelRequest) -> dict:
|
||||||
job_id, workdir = jobs.new_job()
|
job_id, workdir = jobs.new_job()
|
||||||
stats: dict[str, float] = {}
|
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
try:
|
try:
|
||||||
async with render_slot:
|
async with render_slot:
|
||||||
return await _process(request, job_id, workdir, stats, started)
|
return await handler(request, job_id, workdir, started)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
jobs.remove_job(job_id)
|
jobs.remove_job(job_id)
|
||||||
raise
|
raise
|
||||||
@@ -140,7 +153,8 @@ async def get_file(job_id: str, name: str):
|
|||||||
path = jobs.job_file(job_id, name)
|
path = jobs.job_file(job_id, name)
|
||||||
if not path:
|
if not path:
|
||||||
raise HTTPException(status_code=404, detail="Fichier introuvable ou expiré")
|
raise HTTPException(status_code=404, detail="Fichier introuvable ou expiré")
|
||||||
return FileResponse(path, media_type="video/mp4" if name.endswith(".mp4") else None)
|
media_type = {"mp4": "video/mp4", "wav": "audio/wav"}.get(name.rsplit(".", 1)[-1])
|
||||||
|
return FileResponse(path, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/jobs/{job_id}", dependencies=[Depends(require_key)])
|
@app.delete("/jobs/{job_id}", dependencies=[Depends(require_key)])
|
||||||
@@ -185,8 +199,23 @@ async def _download(url: str, target: Path, what: str, required: bool) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _process(request: ReelRequest, job_id: str, workdir: Path, stats: dict, started: float) -> dict:
|
class Stopwatch:
|
||||||
step = time.monotonic()
|
def __init__(self, started: float):
|
||||||
|
self.started = started
|
||||||
|
self.last = time.monotonic()
|
||||||
|
self.stats: dict[str, float] = {}
|
||||||
|
|
||||||
|
def lap(self, name: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
self.stats[name] = round(now - self.last, 2)
|
||||||
|
self.last = now
|
||||||
|
|
||||||
|
def summary(self) -> dict[str, float]:
|
||||||
|
return {**self.stats, "total": round(time.monotonic() - self.started, 2)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _gather(request: ReelRequest, workdir: Path, clock: Stopwatch, *, fetch_logo: bool):
|
||||||
|
"""Téléchargements, analyse, voix et stabilisation : communs aux deux modes."""
|
||||||
video = workdir / "input.mp4"
|
video = workdir / "input.mp4"
|
||||||
if request.video_base64:
|
if request.video_base64:
|
||||||
video.write_bytes(base64.b64decode(request.video_base64))
|
video.write_bytes(base64.b64decode(request.video_base64))
|
||||||
@@ -200,21 +229,21 @@ async def _process(request: ReelRequest, job_id: str, workdir: Path, stats: dict
|
|||||||
request.music_url, music, "la musique", required=False
|
request.music_url, music, "la musique", required=False
|
||||||
)
|
)
|
||||||
watermark = workdir / "watermark.png"
|
watermark = workdir / "watermark.png"
|
||||||
has_watermark = bool(request.watermark_url) and await _download(
|
has_watermark = (
|
||||||
request.watermark_url, watermark, "le logo", required=False
|
fetch_logo
|
||||||
|
and bool(request.watermark_url)
|
||||||
|
and await _download(request.watermark_url, watermark, "le logo", required=False)
|
||||||
)
|
)
|
||||||
info = await proc.probe(video)
|
info = await proc.probe(video)
|
||||||
if info.duration <= 0:
|
if info.duration <= 0:
|
||||||
raise HTTPException(status_code=400, detail="Vidéo illisible (durée nulle)")
|
raise HTTPException(status_code=400, detail="Vidéo illisible (durée nulle)")
|
||||||
stats["download"] = time.monotonic() - step
|
clock.lap("download")
|
||||||
|
|
||||||
# --- Voix ---
|
|
||||||
step = time.monotonic()
|
|
||||||
track = None
|
track = None
|
||||||
spoken_text = clean_text(request.text)
|
spoken_text = clean_text(request.text)
|
||||||
if request.tts_enabled and spoken_text:
|
if request.tts_enabled and spoken_text:
|
||||||
track = await _synthesize(spoken_text, request.text, request, workdir)
|
track = await _synthesize(spoken_text, request.text, request, workdir)
|
||||||
stats["tts"] = time.monotonic() - step
|
clock.lap("tts")
|
||||||
|
|
||||||
plan = render.RenderPlan(
|
plan = render.RenderPlan(
|
||||||
video=video,
|
video=video,
|
||||||
@@ -226,30 +255,11 @@ async def _process(request: ReelRequest, job_id: str, workdir: Path, stats: dict
|
|||||||
voice=track.path if track else None,
|
voice=track.path if track else None,
|
||||||
voice_duration=track.duration if track else 0.0,
|
voice_duration=track.duration if track else 0.0,
|
||||||
watermark=watermark if has_watermark else None,
|
watermark=watermark if has_watermark else None,
|
||||||
|
outro_expected=not fetch_logo and request.has_logo,
|
||||||
ending_effect=request.enable_ending_effect,
|
ending_effect=request.enable_ending_effect,
|
||||||
keep_original_audio=info.has_audio,
|
keep_original_audio=info.has_audio,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Sous-titres ---
|
|
||||||
step = time.monotonic()
|
|
||||||
font_size = max(48, round(request.font_size * 1.4))
|
|
||||||
display = clean_text(request.text)
|
|
||||||
if request.draw_text and display:
|
|
||||||
captions = workdir / "captions.ass"
|
|
||||||
if track:
|
|
||||||
subtitles.write_captions(track.words, captions, offset=plan.voice_delay, font_size=font_size)
|
|
||||||
else:
|
|
||||||
words = await _caption_words_without_voice(display, video, info, plan)
|
|
||||||
subtitles.write_captions(words, captions, offset=0.0, font_size=font_size)
|
|
||||||
plan.captions = captions
|
|
||||||
if request.store_name and request.enable_ending_effect and has_watermark:
|
|
||||||
outro = workdir / "outro.ass"
|
|
||||||
subtitles.write_outro(request.store_name, outro, plan.logo_start, plan.total_duration)
|
|
||||||
plan.outro = outro
|
|
||||||
stats["subtitles"] = time.monotonic() - step
|
|
||||||
|
|
||||||
# --- Stabilisation (1re passe) ---
|
|
||||||
step = time.monotonic()
|
|
||||||
if request.stabilize:
|
if request.stabilize:
|
||||||
transforms = workdir / "transforms.trf"
|
transforms = workdir / "transforms.trf"
|
||||||
try:
|
try:
|
||||||
@@ -257,36 +267,95 @@ async def _process(request: ReelRequest, job_id: str, workdir: Path, stats: dict
|
|||||||
plan.stabilize_transforms = transforms
|
plan.stabilize_transforms = transforms
|
||||||
except proc.CommandError as error:
|
except proc.CommandError as error:
|
||||||
log.warning("Stabilisation ignorée : %s", error)
|
log.warning("Stabilisation ignorée : %s", error)
|
||||||
stats["stabilize"] = time.monotonic() - step
|
clock.lap("stabilize")
|
||||||
|
return plan, track, info
|
||||||
|
|
||||||
# --- Encodage ---
|
|
||||||
step = time.monotonic()
|
|
||||||
await proc.run(render.build_command(plan), timeout=1200)
|
|
||||||
stats["encode"] = time.monotonic() - step
|
|
||||||
stats["total"] = time.monotonic() - started
|
|
||||||
|
|
||||||
duration = (await proc.probe(plan.output)).duration
|
async def _caption_words(request: ReelRequest, plan: render.RenderPlan, track, info) -> list[align.Word]:
|
||||||
log.info(
|
"""Mots à afficher, en secondes depuis le début de la vidéo."""
|
||||||
"Rendu %s terminé : %.1f s de vidéo, étapes %s",
|
display = clean_text(request.text)
|
||||||
job_id,
|
if not request.draw_text or not display:
|
||||||
duration,
|
return []
|
||||||
{k: round(v, 1) for k, v in stats.items()},
|
if track:
|
||||||
)
|
return [align.Word(w.text, w.start + plan.voice_delay, w.end + plan.voice_delay) for w in track.words]
|
||||||
|
return await _caption_words_without_voice(display, plan.video, info, plan)
|
||||||
|
|
||||||
# Seul le résultat est conservé jusqu'au téléchargement
|
|
||||||
|
def _keep_only(workdir: Path, keep: set[Path]) -> None:
|
||||||
|
"""Seuls les fichiers à télécharger restent jusqu'à la récupération."""
|
||||||
for entry in workdir.iterdir():
|
for entry in workdir.iterdir():
|
||||||
if entry != plan.output:
|
if entry not in keep:
|
||||||
entry.unlink(missing_ok=True)
|
entry.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _voice_info(track) -> dict:
|
||||||
|
return {
|
||||||
|
"tts_engine": track.engine if track else None,
|
||||||
|
"tts_voice": track.voice if track else None,
|
||||||
|
"warnings": track.warnings if track else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _process(request: ReelRequest, job_id: str, workdir: Path, started: float) -> dict:
|
||||||
|
clock = Stopwatch(started)
|
||||||
|
plan, track, info = await _gather(request, workdir, clock, fetch_logo=True)
|
||||||
|
|
||||||
|
words = await _caption_words(request, plan, track, info)
|
||||||
|
if words:
|
||||||
|
captions = workdir / "captions.ass"
|
||||||
|
font_size = max(48, round(request.font_size * 1.4))
|
||||||
|
subtitles.write_captions(words, captions, offset=0.0, font_size=font_size)
|
||||||
|
plan.captions = captions
|
||||||
|
if request.store_name and plan.has_outro and plan.watermark:
|
||||||
|
outro = workdir / "outro.ass"
|
||||||
|
subtitles.write_outro(request.store_name, outro, plan.logo_start, plan.total_duration)
|
||||||
|
plan.outro = outro
|
||||||
|
clock.lap("subtitles")
|
||||||
|
|
||||||
|
await proc.run(render.build_command(plan), timeout=1200)
|
||||||
|
clock.lap("encode")
|
||||||
|
duration = (await proc.probe(plan.output)).duration
|
||||||
|
log.info("Rendu %s terminé : %.1f s de vidéo, étapes %s", job_id, duration, clock.summary())
|
||||||
|
|
||||||
|
_keep_only(workdir, {plan.output})
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"job_id": job_id,
|
"job_id": job_id,
|
||||||
"output_path": f"/files/{job_id}/output.mp4",
|
"output_path": f"/files/{job_id}/output.mp4",
|
||||||
"duration": duration,
|
"duration": duration,
|
||||||
"tts_engine": track.engine if track else None,
|
**_voice_info(track),
|
||||||
"tts_voice": track.voice if track else None,
|
"processing_stats": clock.summary(),
|
||||||
"warnings": track.warnings if track else [],
|
}
|
||||||
"processing_stats": stats,
|
|
||||||
|
|
||||||
|
async def _prepare(request: ReelRequest, job_id: str, workdir: Path, started: float) -> dict:
|
||||||
|
clock = Stopwatch(started)
|
||||||
|
plan, track, info = await _gather(request, workdir, clock, fetch_logo=False)
|
||||||
|
words = await _caption_words(request, plan, track, info)
|
||||||
|
clock.lap("subtitles")
|
||||||
|
|
||||||
|
video_out = workdir / "video.mp4"
|
||||||
|
audio_out = workdir / "audio.wav"
|
||||||
|
await proc.run(render.build_prepared_video_command(plan, video_out), timeout=1200)
|
||||||
|
clock.lap("video")
|
||||||
|
mix = render.build_audio_mix_command(plan, audio_out)
|
||||||
|
if mix:
|
||||||
|
await proc.run(mix, timeout=600)
|
||||||
|
clock.lap("audio")
|
||||||
|
log.info("Préparation %s terminée : %.1f s, étapes %s", job_id, plan.total_duration, clock.summary())
|
||||||
|
|
||||||
|
_keep_only(workdir, {video_out, audio_out})
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"job_id": job_id,
|
||||||
|
"video_path": f"/files/{job_id}/video.mp4",
|
||||||
|
"audio_path": f"/files/{job_id}/audio.wav" if mix else None,
|
||||||
|
"total_duration": plan.total_duration,
|
||||||
|
"video_duration": info.duration,
|
||||||
|
"logo_start": plan.logo_start if plan.has_outro else None,
|
||||||
|
"words": [w.to_dict() for w in words],
|
||||||
|
**_voice_info(track),
|
||||||
|
"processing_stats": clock.summary(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -300,5 +369,5 @@ async def _caption_words_without_voice(display: str, video: Path, info, plan: re
|
|||||||
return align.align_words(display, spoken, info.duration)
|
return align.align_words(display, spoken, info.duration)
|
||||||
except Exception as error: # noqa: BLE001 — on retombe sur la répartition
|
except Exception as error: # noqa: BLE001 — on retombe sur la répartition
|
||||||
log.warning("Transcription de la vidéo impossible : %s", error)
|
log.warning("Transcription de la vidéo impossible : %s", error)
|
||||||
end = plan.logo_start if plan.ending_effect and plan.watermark else plan.total_duration
|
end = plan.logo_start if plan.has_outro else plan.total_duration
|
||||||
return align.align_words(display, [], max(1.0, end - 0.5))
|
return align.align_words(display, [], max(1.0, end - 0.5))
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
"""Construction de la commande FFmpeg d'un Reel (fonction pure, testable)."""
|
"""Construction des commandes FFmpeg d'un Reel (fonctions pures, testables).
|
||||||
|
|
||||||
|
Deux usages :
|
||||||
|
- rendu complet par FFmpeg (sous-titres ASS incrustés) : `build_command` ;
|
||||||
|
- préparation pour Remotion, qui compose ensuite les sous-titres animés, le
|
||||||
|
logo et l'effet de fin : `build_prepared_video_command` (image seule, déjà
|
||||||
|
recadrée et allongée) et `build_audio_mix_command` (piste son finale).
|
||||||
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -19,6 +26,8 @@ TONEMAP = (
|
|||||||
"tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
|
"tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
COLOR_TAGS = ["-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RenderPlan:
|
class RenderPlan:
|
||||||
@@ -35,9 +44,15 @@ class RenderPlan:
|
|||||||
captions: Path | None = None
|
captions: Path | None = None
|
||||||
watermark: Path | None = None
|
watermark: Path | None = None
|
||||||
outro: Path | None = None # nom du magasin (effet de fin)
|
outro: Path | None = None # nom du magasin (effet de fin)
|
||||||
|
# Effet de fin prévu sans que le logo passe par FFmpeg (rendu Remotion)
|
||||||
|
outro_expected: bool = False
|
||||||
ending_effect: bool = True
|
ending_effect: bool = True
|
||||||
keep_original_audio: bool = False
|
keep_original_audio: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_outro(self) -> bool:
|
||||||
|
return self.ending_effect and (self.watermark is not None or self.outro_expected)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def speech_end(self) -> float:
|
def speech_end(self) -> float:
|
||||||
return self.voice_delay + self.voice_duration if self.voice else 0.0
|
return self.voice_delay + self.voice_duration if self.voice else 0.0
|
||||||
@@ -48,42 +63,26 @@ class RenderPlan:
|
|||||||
duration = self.video_duration
|
duration = self.video_duration
|
||||||
if self.voice:
|
if self.voice:
|
||||||
duration = max(duration, self.speech_end + VOICE_TAIL)
|
duration = max(duration, self.speech_end + VOICE_TAIL)
|
||||||
if self.ending_effect and self.watermark:
|
if self.has_outro:
|
||||||
duration = max(duration, self.speech_end + OUTRO_MIN)
|
duration = max(duration, self.speech_end + OUTRO_MIN)
|
||||||
return round(duration, 3)
|
return round(duration, 3)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logo_start(self) -> float:
|
def logo_start(self) -> float:
|
||||||
"""Le grand logo n'arrive qu'une fois la voix terminée."""
|
"""Le grand logo n'arrive qu'une fois la voix terminée."""
|
||||||
return max(0.0, self.total_duration - LOGO_SECONDS, self.speech_end)
|
return round(max(0.0, self.total_duration - LOGO_SECONDS, self.speech_end), 3)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fade_start(self) -> float:
|
||||||
|
return max(0.0, self.total_duration - FADE_SECONDS)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def freeze_duration(self) -> float:
|
def freeze_duration(self) -> float:
|
||||||
return max(0.0, self.total_duration - self.video_duration)
|
return max(0.0, self.total_duration - self.video_duration)
|
||||||
|
|
||||||
|
|
||||||
def build_command(plan: RenderPlan) -> list[str]:
|
def video_filters(plan: RenderPlan) -> list[str]:
|
||||||
total = plan.total_duration
|
"""Stabilisation, HDR, recadrage 9:16, cadence fixe, dernière image figée."""
|
||||||
logo_start = plan.logo_start
|
|
||||||
fade_start = max(0.0, total - FADE_SECONDS)
|
|
||||||
|
|
||||||
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video)]
|
|
||||||
index = 1
|
|
||||||
music_idx = voice_idx = wm_idx = None
|
|
||||||
if plan.music:
|
|
||||||
# Musique bouclée : une piste plus courte que la vidéo ne coupe plus le son
|
|
||||||
cmd += ["-stream_loop", "-1", "-i", str(plan.music)]
|
|
||||||
music_idx, index = index, index + 1
|
|
||||||
if plan.voice:
|
|
||||||
cmd += ["-i", str(plan.voice)]
|
|
||||||
voice_idx, index = index, index + 1
|
|
||||||
if plan.watermark:
|
|
||||||
cmd += ["-i", str(plan.watermark)]
|
|
||||||
wm_idx, index = index, index + 1
|
|
||||||
|
|
||||||
graph: list[str] = []
|
|
||||||
|
|
||||||
# --- Vidéo ---
|
|
||||||
chain = []
|
chain = []
|
||||||
if plan.stabilize_transforms:
|
if plan.stabilize_transforms:
|
||||||
chain.append(
|
chain.append(
|
||||||
@@ -98,35 +97,15 @@ def build_command(plan: RenderPlan) -> list[str]:
|
|||||||
)
|
)
|
||||||
if plan.freeze_duration > 0:
|
if plan.freeze_duration > 0:
|
||||||
chain.append(f"tpad=stop_mode=clone:stop_duration={plan.freeze_duration:.3f}")
|
chain.append(f"tpad=stop_mode=clone:stop_duration={plan.freeze_duration:.3f}")
|
||||||
if plan.captions:
|
return chain
|
||||||
chain.append(f"subtitles='{filter_path(plan.captions)}'")
|
|
||||||
graph.append(f"[0:v]{','.join(chain)}[vbase]")
|
|
||||||
|
|
||||||
current = "vbase"
|
|
||||||
if wm_idx is not None:
|
|
||||||
corner = "W-w-30:H-h-30"
|
|
||||||
if plan.outro and plan.ending_effect:
|
|
||||||
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:
|
|
||||||
graph.append(f"[{wm_idx}:v]scale=200:-1[wm_small]")
|
|
||||||
graph.append(f"[{current}][wm_small]overlay={corner}[vwm1]")
|
|
||||||
current = "vwm1"
|
|
||||||
if plan.outro and plan.ending_effect:
|
|
||||||
graph.append(f"[{current}]subtitles='{filter_path(plan.outro)}'[vout0]")
|
|
||||||
current = "vout0"
|
|
||||||
|
|
||||||
tail = []
|
def audio_graph(
|
||||||
if plan.ending_effect:
|
plan: RenderPlan, music_idx: int | None, voice_idx: int | None
|
||||||
tail.append(f"fade=t=out:st={fade_start:.3f}:d={FADE_SECONDS}")
|
) -> tuple[list[str], str | None]:
|
||||||
tail.append("format=yuv420p")
|
"""Graphe audio : voix décalée, musique bouclée et baissée sous la voix,
|
||||||
graph.append(f"[{current}]{','.join(tail)}[vout]")
|
niveau final ~ -14 LUFS. Renvoie les filtres et l'étiquette de sortie."""
|
||||||
|
graph: list[str] = []
|
||||||
# --- Audio ---
|
|
||||||
mix = None
|
|
||||||
if voice_idx is not None:
|
if voice_idx is not None:
|
||||||
delay_ms = int(plan.voice_delay * 1000)
|
delay_ms = int(plan.voice_delay * 1000)
|
||||||
graph.append(f"[{voice_idx}:a]aresample=48000,adelay={delay_ms}:all=1,apad[voice]")
|
graph.append(f"[{voice_idx}:a]aresample=48000,adelay={delay_ms}:all=1,apad[voice]")
|
||||||
@@ -143,75 +122,135 @@ def build_command(plan: RenderPlan) -> list[str]:
|
|||||||
mix = "voice"
|
mix = "voice"
|
||||||
elif music_idx is not None:
|
elif music_idx is not None:
|
||||||
mix = "music"
|
mix = "music"
|
||||||
|
else:
|
||||||
|
return graph, None
|
||||||
|
|
||||||
audio_map: list[str] = []
|
tail = ["loudnorm=I=-14:TP=-1.5:LRA=11", "aresample=48000"]
|
||||||
if mix:
|
|
||||||
# Niveau final recommandé par les réseaux sociaux (~ -14 LUFS)
|
|
||||||
audio_tail = ["loudnorm=I=-14:TP=-1.5:LRA=11", "aresample=48000"]
|
|
||||||
if plan.ending_effect:
|
if plan.ending_effect:
|
||||||
audio_tail.append(f"afade=t=out:st={fade_start:.3f}:d={FADE_SECONDS}")
|
tail.append(f"afade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
|
||||||
graph.append(f"[{mix}]{','.join(audio_tail)}[aout]")
|
graph.append(f"[{mix}]{','.join(tail)}[aout]")
|
||||||
audio_map = ["-map", "[aout]"]
|
return graph, "aout"
|
||||||
|
|
||||||
|
|
||||||
|
def _audio_inputs(plan: RenderPlan, first_index: int) -> tuple[list[str], int | None, int | None, int]:
|
||||||
|
args: list[str] = []
|
||||||
|
index = first_index
|
||||||
|
music_idx = voice_idx = None
|
||||||
|
if plan.music:
|
||||||
|
# Musique bouclée : une piste plus courte que la vidéo ne coupe plus le son
|
||||||
|
args += ["-stream_loop", "-1", "-i", str(plan.music)]
|
||||||
|
music_idx, index = index, index + 1
|
||||||
|
if plan.voice:
|
||||||
|
args += ["-i", str(plan.voice)]
|
||||||
|
voice_idx, index = index, index + 1
|
||||||
|
return args, music_idx, voice_idx, index
|
||||||
|
|
||||||
|
|
||||||
|
def _video_encoding(crf: int, preset: str) -> list[str]:
|
||||||
|
return [
|
||||||
|
"-c:v", "libx264", "-preset", preset, "-crf", str(crf),
|
||||||
|
"-maxrate", "10M", "-bufsize", "20M",
|
||||||
|
"-profile:v", "high", "-level", "4.2",
|
||||||
|
"-g", str(config.FPS * 2), "-keyint_min", str(config.FPS),
|
||||||
|
"-pix_fmt", "yuv420p", *COLOR_TAGS,
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def build_command(plan: RenderPlan) -> list[str]:
|
||||||
|
"""Rendu complet par FFmpeg, sous-titres ASS incrustés."""
|
||||||
|
total = plan.total_duration
|
||||||
|
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video)]
|
||||||
|
audio_args, music_idx, voice_idx, index = _audio_inputs(plan, 1)
|
||||||
|
cmd += audio_args
|
||||||
|
wm_idx = None
|
||||||
|
if plan.watermark:
|
||||||
|
cmd += ["-i", str(plan.watermark)]
|
||||||
|
wm_idx = index
|
||||||
|
|
||||||
|
chain = video_filters(plan)
|
||||||
|
if plan.captions:
|
||||||
|
chain.append(f"subtitles='{filter_path(plan.captions)}'")
|
||||||
|
graph = [f"[0:v]{','.join(chain)}[vbase]"]
|
||||||
|
|
||||||
|
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:
|
||||||
|
graph.append(f"[{wm_idx}:v]scale=200:-1[wm_small]")
|
||||||
|
graph.append(f"[{current}][wm_small]overlay={corner}[vwm1]")
|
||||||
|
current = "vwm1"
|
||||||
|
if plan.outro and plan.ending_effect:
|
||||||
|
graph.append(f"[{current}]subtitles='{filter_path(plan.outro)}'[vout0]")
|
||||||
|
current = "vout0"
|
||||||
|
|
||||||
|
tail = []
|
||||||
|
if plan.ending_effect:
|
||||||
|
tail.append(f"fade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
|
||||||
|
tail.append("format=yuv420p")
|
||||||
|
graph.append(f"[{current}]{','.join(tail)}[vout]")
|
||||||
|
|
||||||
|
audio_filters, audio_out = audio_graph(plan, music_idx, voice_idx)
|
||||||
|
graph += audio_filters
|
||||||
|
if audio_out:
|
||||||
|
audio_map = ["-map", f"[{audio_out}]"]
|
||||||
elif plan.keep_original_audio:
|
elif plan.keep_original_audio:
|
||||||
audio_map = ["-map", "0:a:0"]
|
audio_map = ["-map", "0:a:0"]
|
||||||
|
else:
|
||||||
|
audio_map = []
|
||||||
|
|
||||||
cmd += ["-filter_complex", ";".join(graph), "-map", "[vout]", *audio_map]
|
cmd += ["-filter_complex", ";".join(graph), "-map", "[vout]", *audio_map]
|
||||||
cmd += [
|
cmd += ["-t", f"{total:.3f}", *_video_encoding(19, "medium")]
|
||||||
"-t",
|
cmd += ["-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2", "-movflags", "+faststart"]
|
||||||
f"{total:.3f}",
|
cmd.append(str(plan.output))
|
||||||
"-c:v",
|
|
||||||
"libx264",
|
|
||||||
"-preset",
|
|
||||||
"medium",
|
|
||||||
"-crf",
|
|
||||||
"19",
|
|
||||||
"-maxrate",
|
|
||||||
"10M",
|
|
||||||
"-bufsize",
|
|
||||||
"20M",
|
|
||||||
"-profile:v",
|
|
||||||
"high",
|
|
||||||
"-level",
|
|
||||||
"4.2",
|
|
||||||
"-g",
|
|
||||||
str(config.FPS * 2),
|
|
||||||
"-keyint_min",
|
|
||||||
str(config.FPS),
|
|
||||||
"-pix_fmt",
|
|
||||||
"yuv420p",
|
|
||||||
"-color_primaries",
|
|
||||||
"bt709",
|
|
||||||
"-color_trc",
|
|
||||||
"bt709",
|
|
||||||
"-colorspace",
|
|
||||||
"bt709",
|
|
||||||
"-c:a",
|
|
||||||
"aac",
|
|
||||||
"-b:a",
|
|
||||||
"192k",
|
|
||||||
"-ar",
|
|
||||||
"48000",
|
|
||||||
"-ac",
|
|
||||||
"2",
|
|
||||||
"-movflags",
|
|
||||||
"+faststart",
|
|
||||||
str(plan.output),
|
|
||||||
]
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def build_prepared_video_command(plan: RenderPlan, output: Path) -> list[str]:
|
||||||
|
"""Image seule, prête pour Remotion : recadrée, en cadence fixe, à la durée finale.
|
||||||
|
Qualité élevée (CRF 16) : elle sera réencodée une fois par Remotion."""
|
||||||
|
chain = [*video_filters(plan), "format=yuv420p"]
|
||||||
|
return [
|
||||||
|
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video),
|
||||||
|
"-vf", ",".join(chain), "-an", "-t", f"{plan.total_duration:.3f}",
|
||||||
|
*_video_encoding(16, "veryfast"), "-movflags", "+faststart", str(output),
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def build_audio_mix_command(plan: RenderPlan, output: Path) -> list[str] | None:
|
||||||
|
"""Piste son finale (WAV 48 kHz stéréo), ou None s'il n'y a aucun son à produire."""
|
||||||
|
total = f"{plan.total_duration:.3f}"
|
||||||
|
if not plan.music and not plan.voice:
|
||||||
|
if not plan.keep_original_audio:
|
||||||
|
return None
|
||||||
|
# Son d'origine seul : même niveau final que les autres Reels
|
||||||
|
chain = ["aresample=48000", "loudnorm=I=-14:TP=-1.5:LRA=11", "apad"]
|
||||||
|
if plan.ending_effect:
|
||||||
|
chain.append(f"afade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
|
||||||
|
return [
|
||||||
|
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video),
|
||||||
|
"-map", "0:a:0", "-af", ",".join(chain), "-t", total,
|
||||||
|
"-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", str(output),
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
audio_args, music_idx, voice_idx, _ = _audio_inputs(plan, 0)
|
||||||
|
graph, audio_out = audio_graph(plan, music_idx, voice_idx)
|
||||||
|
return [
|
||||||
|
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", *audio_args,
|
||||||
|
"-filter_complex", ";".join(graph), "-map", f"[{audio_out}]", "-t", total,
|
||||||
|
"-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", str(output),
|
||||||
|
] # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
def stabilize_detect_command(video: Path, transforms: Path) -> list[str]:
|
def stabilize_detect_command(video: Path, transforms: Path) -> list[str]:
|
||||||
return [
|
return [
|
||||||
"ffmpeg",
|
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(video),
|
||||||
"-y",
|
"-vf", f"vidstabdetect=stepsize=32:shakiness=8:accuracy=15:result={filter_path(transforms)}",
|
||||||
"-hide_banner",
|
"-f", "null", "-",
|
||||||
"-loglevel",
|
] # fmt: skip
|
||||||
"error",
|
|
||||||
"-i",
|
|
||||||
str(video),
|
|
||||||
"-vf",
|
|
||||||
f"vidstabdetect=stepsize=32:shakiness=8:accuracy=15:result={filter_path(transforms)}",
|
|
||||||
"-f",
|
|
||||||
"null",
|
|
||||||
"-",
|
|
||||||
]
|
|
||||||
@@ -75,3 +75,29 @@ def test_big_logo_waits_for_the_end_of_the_voice():
|
|||||||
assert plan.logo_start == 6.8
|
assert plan.logo_start == 6.8
|
||||||
assert plan.total_duration == 9.3
|
assert plan.total_duration == 9.3
|
||||||
assert "gte(t,6.800)" in _graph(build_command(plan))
|
assert "gte(t,6.800)" in _graph(build_command(plan))
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepared_video_has_no_audio_and_final_duration():
|
||||||
|
from app.render import build_prepared_video_command
|
||||||
|
|
||||||
|
plan = RenderPlan(
|
||||||
|
video=Path("in.mp4"),
|
||||||
|
video_duration=6.0,
|
||||||
|
output=Path("out.mp4"),
|
||||||
|
voice=Path("v.wav"),
|
||||||
|
voice_duration=5.0,
|
||||||
|
outro_expected=True,
|
||||||
|
)
|
||||||
|
cmd = build_prepared_video_command(plan, Path("video.mp4"))
|
||||||
|
assert "-an" in cmd
|
||||||
|
assert cmd[cmd.index("-t") + 1] == "9.500" # 2 s + 5 s de voix + 2,5 s d'effet de fin
|
||||||
|
assert plan.logo_start == 7.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_mix_absent_without_any_sound():
|
||||||
|
from app.render import build_audio_mix_command
|
||||||
|
|
||||||
|
plan = RenderPlan(video=Path("in.mp4"), video_duration=6.0, output=Path("out.mp4"))
|
||||||
|
assert build_audio_mix_command(plan, Path("a.wav")) is None
|
||||||
|
plan.keep_original_audio = True
|
||||||
|
assert "loudnorm=I=-14" in " ".join(build_audio_mix_command(plan, Path("a.wav")))
|
||||||
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 168 KiB |
@@ -13,6 +13,7 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@fontsource/montserrat": "^5.3.0",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
"@neondatabase/serverless": "^0.10.4",
|
"@neondatabase/serverless": "^0.10.4",
|
||||||
@@ -128,7 +129,8 @@
|
|||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tsx": "^4.20.5",
|
"tsx": "^4.20.5",
|
||||||
"typescript": "5.6.3",
|
"typescript": "5.6.3",
|
||||||
"vite": "^5.4.20"
|
"vite": "^5.4.20",
|
||||||
|
"vitest": "^3.2.7"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"bufferutil": "^4.0.8"
|
"bufferutil": "^4.0.8"
|
||||||
@@ -2303,6 +2305,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
|
||||||
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="
|
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/montserrat": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-iQPDtZOnmM5ih4JBGaMSXisRap0ixb9bAUw2hDtY7EjVfaqiiNlKtVuJl9XclxUwtwMMHNcWXfQR6zI9x05+Tg==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hookform/resolvers": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
|
||||||
@@ -6885,6 +6896,17 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/connect": {
|
"node_modules/@types/connect": {
|
||||||
"version": "3.4.38",
|
"version": "3.4.38",
|
||||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
@@ -6969,6 +6991,13 @@
|
|||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/dom-mediacapture-transform": {
|
"node_modules/@types/dom-mediacapture-transform": {
|
||||||
"version": "0.1.11",
|
"version": "0.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz",
|
||||||
@@ -7242,6 +7271,94 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "3.2.7",
|
||||||
|
"@vitest/utils": "3.2.7",
|
||||||
|
"chai": "^5.2.0",
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "3.2.7",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"strip-literal": "^3.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "3.2.7",
|
||||||
|
"magic-string": "^0.30.17",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyspy": "^4.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "3.2.7",
|
||||||
|
"loupe": "^3.1.4",
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@webassemblyjs/ast": {
|
"node_modules/@webassemblyjs/ast": {
|
||||||
"version": "1.14.1",
|
"version": "1.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||||
@@ -7612,6 +7729,16 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ast-types": {
|
"node_modules/ast-types": {
|
||||||
"version": "0.16.1",
|
"version": "0.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
|
||||||
@@ -7887,6 +8014,16 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cac": {
|
||||||
|
"version": "6.7.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||||
|
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/call-bind": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
|
||||||
@@ -7963,6 +8100,33 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "5.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||||
|
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"assertion-error": "^2.0.1",
|
||||||
|
"check-error": "^2.1.1",
|
||||||
|
"deep-eql": "^5.0.1",
|
||||||
|
"loupe": "^3.1.0",
|
||||||
|
"pathval": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/check-error": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@@ -8440,6 +8604,16 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/deep-eql": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/define-data-property": {
|
"node_modules/define-data-property": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||||
@@ -8987,6 +9161,16 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/etag": {
|
"node_modules/etag": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
@@ -9040,6 +9224,16 @@
|
|||||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.21.2",
|
"version": "4.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||||
@@ -10540,6 +10734,13 @@
|
|||||||
"loose-envify": "cli.js"
|
"loose-envify": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/loupe": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -11309,6 +11510,23 @@
|
|||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pathval": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pause": {
|
"node_modules/pause": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||||
@@ -12614,6 +12832,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/signal-exit": {
|
"node_modules/signal-exit": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
@@ -12700,6 +12925,13 @@
|
|||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stackframe": {
|
"node_modules/stackframe": {
|
||||||
"version": "1.3.4",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
||||||
@@ -12715,6 +12947,13 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "3.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
|
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/streamsearch": {
|
"node_modules/streamsearch": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
@@ -12843,6 +13082,26 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-literal": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-tokens": "^9.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-literal/node_modules/js-tokens": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/strnum": {
|
"node_modules/strnum": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
|
||||||
@@ -13192,6 +13451,98 @@
|
|||||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tinyglobby": {
|
||||||
|
"version": "0.2.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fdir": "^6.5.0",
|
||||||
|
"picomatch": "^4.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyglobby/node_modules/fdir": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"picomatch": "^3 || ^4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"picomatch": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinypool": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || >=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyspy": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -13592,6 +13943,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-node": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cac": "^6.7.14",
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"es-module-lexer": "^1.7.0",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vite-node": "vite-node.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-node/node_modules/es-module-lexer": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
|
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||||
@@ -14022,6 +14403,119 @@
|
|||||||
"@esbuild/win32-x64": "0.21.5"
|
"@esbuild/win32-x64": "0.21.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/expect": "3.2.7",
|
||||||
|
"@vitest/mocker": "3.2.7",
|
||||||
|
"@vitest/pretty-format": "^3.2.7",
|
||||||
|
"@vitest/runner": "3.2.7",
|
||||||
|
"@vitest/snapshot": "3.2.7",
|
||||||
|
"@vitest/spy": "3.2.7",
|
||||||
|
"@vitest/utils": "3.2.7",
|
||||||
|
"chai": "^5.2.0",
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"expect-type": "^1.2.1",
|
||||||
|
"magic-string": "^0.30.17",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.2",
|
||||||
|
"std-env": "^3.9.0",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^0.3.2",
|
||||||
|
"tinyglobby": "^0.2.14",
|
||||||
|
"tinypool": "^1.1.1",
|
||||||
|
"tinyrainbow": "^2.0.0",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
||||||
|
"vite-node": "3.2.4",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@types/debug": "^4.1.12",
|
||||||
|
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||||
|
"@vitest/browser": "3.2.7",
|
||||||
|
"@vitest/ui": "3.2.7",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/debug": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@vitest/mocker": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "3.2.7",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/picomatch": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/watchpack": {
|
"node_modules/watchpack": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
|
||||||
@@ -14182,6 +14676,23 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wide-align": {
|
"node_modules/wide-align": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||||
|
|||||||
@@ -8,13 +8,15 @@
|
|||||||
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
|
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
|
||||||
"start": "NODE_ENV=production node dist/index.js",
|
"start": "NODE_ENV=production node dist/index.js",
|
||||||
"check": "tsc",
|
"check": "tsc",
|
||||||
"db:push": "drizzle-kit push"
|
"db:push": "drizzle-kit push",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1016.0",
|
"@aws-sdk/client-s3": "^3.1016.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@fontsource/montserrat": "^5.3.0",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
"@neondatabase/serverless": "^0.10.4",
|
"@neondatabase/serverless": "^0.10.4",
|
||||||
@@ -130,7 +132,8 @@
|
|||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tsx": "^4.20.5",
|
"tsx": "^4.20.5",
|
||||||
"typescript": "5.6.3",
|
"typescript": "5.6.3",
|
||||||
"vite": "^5.4.20"
|
"vite": "^5.4.20",
|
||||||
|
"vitest": "^3.2.7"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"bufferutil": "^4.0.8"
|
"bufferutil": "^4.0.8"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 179 KiB |
@@ -1,9 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Pre-bundle Remotion composition during Docker build.
|
|
||||||
// This warms up the Webpack cache so the first user render is fast.
|
|
||||||
const { bundle } = require('@remotion/bundler');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
bundle({ entryPoint: path.resolve(__dirname, '../client/src/remotion/index.ts') })
|
|
||||||
.then(loc => console.log('✅ Remotion pre-bundle OK:', loc))
|
|
||||||
.catch(e => { console.warn('⚠️ Remotion pre-bundle skipped:', e.message); process.exit(0); });
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Pré-bundle des compositions Remotion pendant le build Docker : le cache
|
||||||
|
// Webpack est chaud, le premier rendu d'un utilisateur démarre plus vite.
|
||||||
|
// Même configuration que server/services/reels/remotionRenderer.ts.
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { bundle } from "@remotion/bundler";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
|
||||||
|
bundle({
|
||||||
|
entryPoint: path.join(root, "client/src/remotion/index.ts"),
|
||||||
|
webpackOverride: (config) => ({
|
||||||
|
...config,
|
||||||
|
resolve: {
|
||||||
|
...config.resolve,
|
||||||
|
alias: { ...(config.resolve?.alias ?? {}), "@shared": path.join(root, "shared") },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then((location) => console.log("✅ Remotion pre-bundle OK:", location))
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn("⚠️ Remotion pre-bundle skipped:", error.message);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -8,7 +8,8 @@ import { storage } from '../storage';
|
|||||||
import { ffmpegService, FFmpegServiceError } from '../services/ffmpeg';
|
import { ffmpegService, FFmpegServiceError } from '../services/ffmpeg';
|
||||||
import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel';
|
import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel';
|
||||||
import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue';
|
import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue';
|
||||||
import { resolveGeminiApiKey, resolveStoreName } from '../services/reels/assets';
|
import { resolveGeminiApiKey, resolveLogoPath, resolveStoreName } from '../services/reels/assets';
|
||||||
|
import { configuredRenderer } from '../services/reels/videoPipeline';
|
||||||
import { openRouterService, describeGenerationError } from '../services/openrouter';
|
import { openRouterService, describeGenerationError } from '../services/openrouter';
|
||||||
|
|
||||||
import { estimateVoiceTiming } from '../services/ttsSync';
|
import { estimateVoiceTiming } from '../services/ttsSync';
|
||||||
@@ -378,12 +379,18 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => {
|
|||||||
*/
|
*/
|
||||||
reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
|
reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const ffmpegConfigured = await ffmpegService.healthCheck().catch(() => false);
|
const [ffmpegConfigured, logoUrl] = await Promise.all([
|
||||||
|
ffmpegService.healthCheck().catch(() => false),
|
||||||
|
resolveLogoPath(),
|
||||||
|
]);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
jamendo: { configured: false },
|
jamendo: { configured: false },
|
||||||
freesound: { configured: false },
|
freesound: { configured: false },
|
||||||
ffmpeg: { configured: ffmpegConfigured },
|
ffmpeg: { configured: ffmpegConfigured },
|
||||||
|
// Aperçu : logo affiché par le lecteur, moteur de rendu utilisé
|
||||||
|
logoUrl: logoUrl ?? null,
|
||||||
|
renderer: configuredRenderer(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error fetching Reels config:', error);
|
console.error('❌ Error fetching Reels config:', error);
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
|
|||||||
ttsEngine: req.body.ttsEngine || undefined,
|
ttsEngine: req.body.ttsEngine || undefined,
|
||||||
ttsVoice: req.body.ttsVoice,
|
ttsVoice: req.body.ttsVoice,
|
||||||
ttsStyle: req.body.ttsStyle || undefined,
|
ttsStyle: req.body.ttsStyle || undefined,
|
||||||
|
captionStyle: req.body.captionStyle || undefined,
|
||||||
storeName: await resolveStoreName(user.id, req.body.selectedPageId),
|
storeName: await resolveStoreName(user.id, req.body.selectedPageId),
|
||||||
tempFiles: uploaded.map((f) => f.path),
|
tempFiles: uploaded.map((f) => f.path),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
* JSON, qui gonflait sa taille d'un tiers et la gardait entière en mémoire.
|
* JSON, qui gonflait sa taille d'un tiers et la gardait entière en mémoire.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import { pipeline } from 'stream/promises';
|
||||||
|
import type { ReadableStream as WebReadableStream } from 'stream/web';
|
||||||
import type { TtsEngine, TtsStyle } from '@shared/voices';
|
import type { TtsEngine, TtsStyle } from '@shared/voices';
|
||||||
|
|
||||||
/** Un rendu long (stabilisation + encodage) peut dépasser plusieurs minutes. */
|
/** Un rendu long (stabilisation + encodage) peut dépasser plusieurs minutes. */
|
||||||
@@ -39,6 +43,19 @@ export interface ReelRenderResult {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rendu préparé pour Remotion : fichiers déjà écrits sur le disque. */
|
||||||
|
export interface PreparedReel {
|
||||||
|
videoPath: string;
|
||||||
|
audioPath: string | null;
|
||||||
|
totalDuration: number;
|
||||||
|
videoDuration: number;
|
||||||
|
logoStart: number | null;
|
||||||
|
words: TimedWord[];
|
||||||
|
ttsEngine?: string | null;
|
||||||
|
ttsVoice?: string | null;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TimedWord {
|
export interface TimedWord {
|
||||||
text: string;
|
text: string;
|
||||||
start: number;
|
start: number;
|
||||||
@@ -164,6 +181,86 @@ export class FFmpegService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prépare un rendu Remotion : image recadrée à la durée finale et piste son
|
||||||
|
* finale, écrites dans `targetDir`, plus le minutage des mots.
|
||||||
|
*/
|
||||||
|
async prepareReel(
|
||||||
|
videoUrl: string,
|
||||||
|
options: ReelRenderOptions & { hasLogo: boolean },
|
||||||
|
targetDir: string,
|
||||||
|
): Promise<PreparedReel> {
|
||||||
|
const response = await this.call('/prepare-reel', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
video_url: videoUrl,
|
||||||
|
text: options.text,
|
||||||
|
music_url: options.musicUrl,
|
||||||
|
tts_enabled: options.ttsEnabled ?? false,
|
||||||
|
tts_voice: options.ttsVoice,
|
||||||
|
tts_engine: options.ttsEngine,
|
||||||
|
tts_style: options.ttsStyle,
|
||||||
|
gemini_api_key: options.geminiApiKey,
|
||||||
|
music_volume: options.musicVolume ?? 0.25,
|
||||||
|
draw_text: options.drawText ?? true,
|
||||||
|
stabilize: options.stabilize ?? false,
|
||||||
|
store_name: options.storeName,
|
||||||
|
enable_ending_effect: options.enableEndingEffect ?? true,
|
||||||
|
has_logo: options.hasLogo,
|
||||||
|
}),
|
||||||
|
timeoutMs: PROCESS_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const data = await response.json() as {
|
||||||
|
job_id: string;
|
||||||
|
video_path: string;
|
||||||
|
audio_path: string | null;
|
||||||
|
total_duration: number;
|
||||||
|
video_duration: number;
|
||||||
|
logo_start: number | null;
|
||||||
|
words: TimedWord[];
|
||||||
|
tts_engine?: string | null;
|
||||||
|
tts_voice?: string | null;
|
||||||
|
warnings?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.mkdir(targetDir, { recursive: true });
|
||||||
|
const videoPath = `${targetDir}/video.mp4`;
|
||||||
|
await this.downloadTo(data.video_path, videoPath);
|
||||||
|
let audioPath: string | null = null;
|
||||||
|
if (data.audio_path) {
|
||||||
|
audioPath = `${targetDir}/audio.wav`;
|
||||||
|
await this.downloadTo(data.audio_path, audioPath);
|
||||||
|
}
|
||||||
|
for (const warning of data.warnings ?? []) console.warn(`⚠️ [FFmpeg] ${warning}`);
|
||||||
|
return {
|
||||||
|
videoPath,
|
||||||
|
audioPath,
|
||||||
|
totalDuration: data.total_duration,
|
||||||
|
videoDuration: data.video_duration,
|
||||||
|
logoStart: data.logo_start,
|
||||||
|
words: data.words ?? [],
|
||||||
|
ttsEngine: data.tts_engine,
|
||||||
|
ttsVoice: data.tts_voice,
|
||||||
|
warnings: data.warnings ?? [],
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.call(`/jobs/${data.job_id}`, { method: 'DELETE', timeoutMs: HEALTH_TIMEOUT_MS })
|
||||||
|
.catch(() => { /* purgé plus tard par le service */ });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Télécharge un fichier du service en flux, sans le charger en mémoire. */
|
||||||
|
private async downloadTo(servicePath: string, target: string): Promise<void> {
|
||||||
|
const response = await this.call(servicePath, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS });
|
||||||
|
if (!response.body) throw new FFmpegServiceError(`Réponse vide pour ${servicePath}`);
|
||||||
|
await pipeline(
|
||||||
|
Readable.fromWeb(response.body as unknown as WebReadableStream<Uint8Array>),
|
||||||
|
fs.createWriteStream(target),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async healthCheck(): Promise<boolean> {
|
async healthCheck(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await this.call('/health', { method: 'GET', timeoutMs: HEALTH_TIMEOUT_MS });
|
await this.call('/health', { method: 'GET', timeoutMs: HEALTH_TIMEOUT_MS });
|
||||||
|
|||||||
@@ -6,113 +6,16 @@
|
|||||||
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { bundle } from "@remotion/bundler";
|
import { cleanCaptionText, computeImagesTiming } from "@shared/captions";
|
||||||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
|
||||||
import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
|
import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
|
||||||
import { ffmpegService, type TimedWord } from "../ffmpeg";
|
import { ffmpegService } from "../ffmpeg";
|
||||||
import { generateVideoThumbnail } from "../thumbnail";
|
import { generateVideoThumbnail } from "../thumbnail";
|
||||||
import type { JobContext } from "./queue";
|
import type { JobContext } from "./queue";
|
||||||
import { resolveGeminiApiKey, resolveLogoPath } from "./assets";
|
import { resolveGeminiApiKey, resolveLogoPath } from "./assets";
|
||||||
|
import { REMOTION_TEMP_DIR, localHttpUrl, tempFileUrl, toDataUrl } from "./remotionAssets";
|
||||||
|
import { renderReelComposition } from "./remotionRenderer";
|
||||||
|
|
||||||
export const REMOTION_TEMP_DIR = path.join(process.cwd(), "uploads", "temp");
|
export { REMOTION_TEMP_DIR };
|
||||||
|
|
||||||
// Durées
|
|
||||||
const FPS = 30;
|
|
||||||
const ENDING_SECONDS = 3; // diapositive de fin (logo + nom du magasin)
|
|
||||||
const MIN_CONTENT_SECONDS = 22; // total >= 25 s
|
|
||||||
const MAX_CONTENT_SECONDS = 27; // total <= 30 s
|
|
||||||
|
|
||||||
/** Bundle Remotion mis en cache : un seul bundle par processus. */
|
|
||||||
let bundleCache: Promise<string> | null = null;
|
|
||||||
|
|
||||||
function getBundle(): Promise<string> {
|
|
||||||
if (!bundleCache) {
|
|
||||||
// process.cwd() = /app dans Docker, racine du projet en dev
|
|
||||||
const entryPoint = path.resolve(process.cwd(), "client/src/remotion/index.ts");
|
|
||||||
console.log("📦 Bundling Remotion from:", entryPoint);
|
|
||||||
bundleCache = bundle({ entryPoint }).catch((error) => {
|
|
||||||
bundleCache = null; // réessayer au prochain rendu
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return bundleCache;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** URL HTTP locale : l'audio est lu par Chromium, trop lourd pour une data URL. */
|
|
||||||
function localHttpUrl(url: string): string {
|
|
||||||
if (!url.startsWith("/")) return url;
|
|
||||||
const port = process.env.PORT || "5555";
|
|
||||||
return `http://localhost:${port}${url}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MIME_BY_EXT: Record<string, string> = {
|
|
||||||
png: "image/png",
|
|
||||||
gif: "image/gif",
|
|
||||||
webp: "image/webp",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convertit un chemin local /uploads/... en data URL : Chromium dans Docker ne
|
|
||||||
* lit pas http://localhost de façon fiable, les images sont donc embarquées.
|
|
||||||
*/
|
|
||||||
async function toDataUrl(relativeUrl: string): Promise<string> {
|
|
||||||
if (!relativeUrl.startsWith("/uploads/")) return relativeUrl;
|
|
||||||
const uploadsRoot = path.join(process.cwd(), "uploads") + path.sep;
|
|
||||||
const filePath = path.resolve(process.cwd(), "." + relativeUrl);
|
|
||||||
if (!filePath.startsWith(uploadsRoot)) {
|
|
||||||
throw new Error(`Chemin d'image refusé : ${relativeUrl}`);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const buffer = await fs.promises.readFile(filePath);
|
|
||||||
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
||||||
const mime = MIME_BY_EXT[ext] ?? "image/jpeg";
|
|
||||||
return `data:${mime};base64,${buffer.toString("base64")}`;
|
|
||||||
} catch {
|
|
||||||
console.warn(`⚠️ toDataUrl: fichier introuvable : ${filePath}`);
|
|
||||||
return relativeUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retire hashtags et emojis avant la synthèse vocale. Les lettres accentuées
|
|
||||||
* font partie du hashtag (#AménagementExtérieur est retiré en entier).
|
|
||||||
*/
|
|
||||||
export function stripForTTS(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/#[\wÀ-ɏḀ-ỿ]*/g, " ")
|
|
||||||
.replace(/[\uD800-\uDFFF][\uDC00-\uDFFF]/g, " ") // paires de substitution (la plupart des emojis)
|
|
||||||
.replace(/[☀-➿]/g, " ") // symboles divers
|
|
||||||
.replace(/[⬀-⯿]/g, " ")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const EDGE_PUNCT = /^[.,!?;:…«»"'()\[\]]+|[.,!?;:…«»"'()\[\]]+$/g;
|
|
||||||
|
|
||||||
export interface WordTiming {
|
|
||||||
word: string;
|
|
||||||
startFrame: number;
|
|
||||||
endFrame: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convertit le minutage réel des mots (en secondes, fourni par la voix) en
|
|
||||||
* images. Chaque mot reste affiché jusqu'au début du suivant : pas de trou
|
|
||||||
* pendant les respirations.
|
|
||||||
*/
|
|
||||||
export function toWordTimings(words: TimedWord[], fps: number): WordTiming[] {
|
|
||||||
const timings = words
|
|
||||||
.map((w) => ({ word: w.text.replace(EDGE_PUNCT, "") || w.text, start: w.start, end: w.end }))
|
|
||||||
.filter((w) => w.word.trim());
|
|
||||||
return timings.map((w, i) => ({
|
|
||||||
word: w.word,
|
|
||||||
startFrame: Math.round(w.start * fps),
|
|
||||||
endFrame: Math.max(
|
|
||||||
Math.round(w.start * fps) + 1,
|
|
||||||
Math.round((i + 1 < timings.length ? timings[i + 1].start : w.end) * fps),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runImagesReelJob({ job, progress }: JobContext): Promise<ImagesReelResult> {
|
export async function runImagesReelJob({ job, progress }: JobContext): Promise<ImagesReelResult> {
|
||||||
const params = imagesReelParamsSchema.parse(job.params);
|
const params = imagesReelParamsSchema.parse(job.params);
|
||||||
@@ -126,15 +29,15 @@ export async function runImagesReelJob({ job, progress }: JobContext): Promise<I
|
|||||||
const musicUrl = params.musicUrl ? localHttpUrl(params.musicUrl) : undefined;
|
const musicUrl = params.musicUrl ? localHttpUrl(params.musicUrl) : undefined;
|
||||||
const { overlayText, storeName } = params;
|
const { overlayText, storeName } = params;
|
||||||
|
|
||||||
// --- Voix ---
|
// --- Voix et minutage réel des mots ---
|
||||||
let audioUrl: string | undefined;
|
let audioUrl: string | undefined;
|
||||||
let wordTimings: WordTiming[] | undefined;
|
let words: { text: string; start: number; end: number }[] = [];
|
||||||
let audioDuration = 0;
|
let audioDuration = 0;
|
||||||
|
|
||||||
const ttsText = overlayText ? stripForTTS(overlayText) : "";
|
const spokenText = overlayText ? cleanCaptionText(overlayText) : "";
|
||||||
if (params.ttsEnabled && overlayText && ttsText) {
|
if (params.ttsEnabled && spokenText) {
|
||||||
await progress(15, "voice");
|
await progress(15, "voice");
|
||||||
const voice = await ffmpegService.previewVoice(ttsText, {
|
const voice = await ffmpegService.previewVoice(spokenText, {
|
||||||
voice: params.ttsVoice,
|
voice: params.ttsVoice,
|
||||||
engine: params.ttsEngine,
|
engine: params.ttsEngine,
|
||||||
style: params.ttsStyle,
|
style: params.ttsStyle,
|
||||||
@@ -142,52 +45,40 @@ export async function runImagesReelJob({ job, progress }: JobContext): Promise<I
|
|||||||
});
|
});
|
||||||
for (const warning of voice.warnings) console.warn(`⚠️ [Reels] ${warning}`);
|
for (const warning of voice.warnings) console.warn(`⚠️ [Reels] ${warning}`);
|
||||||
|
|
||||||
const audioFilename = `tts-${job.id}.mp3`;
|
const audioPath = path.join(REMOTION_TEMP_DIR, `tts-${job.id}.mp3`);
|
||||||
const audioPath = path.join(REMOTION_TEMP_DIR, audioFilename);
|
|
||||||
await fs.promises.writeFile(audioPath, voice.audio);
|
await fs.promises.writeFile(audioPath, voice.audio);
|
||||||
jobTempFiles.push(audioPath);
|
jobTempFiles.push(audioPath);
|
||||||
audioUrl = localHttpUrl(`/uploads/temp/${audioFilename}`);
|
audioUrl = tempFileUrl(audioPath);
|
||||||
audioDuration = voice.duration;
|
audioDuration = voice.duration;
|
||||||
wordTimings = toWordTimings(voice.words, FPS);
|
words = voice.words;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Durée : 25 à 30 s ---
|
// --- Durée : 25 à 30 s (mêmes règles que l'aperçu) ---
|
||||||
const naturalContent = Math.max(audioDuration, images.length * 3);
|
const { total, endingSeconds } = computeImagesTiming({
|
||||||
const contentSeconds = Math.min(Math.max(naturalContent, MIN_CONTENT_SECONDS), MAX_CONTENT_SECONDS);
|
imageCount: images.length,
|
||||||
const endingFrames = logoUrl || storeName ? ENDING_SECONDS * FPS : 0;
|
voiceDuration: audioDuration,
|
||||||
const totalFrames = Math.round(contentSeconds * FPS) + endingFrames;
|
hasEnding: Boolean(logoUrl || storeName),
|
||||||
|
});
|
||||||
|
|
||||||
await progress(25, "render");
|
await progress(25, "render");
|
||||||
const serveUrl = await getBundle();
|
const outputLocation = path.join(REMOTION_TEMP_DIR, `out-${job.id}.mp4`);
|
||||||
const inputProps = {
|
let lastReported = 25;
|
||||||
|
await renderReelComposition({
|
||||||
|
compositionId: "ImageVideo",
|
||||||
|
outputLocation,
|
||||||
|
inputProps: {
|
||||||
images,
|
images,
|
||||||
overlayText,
|
totalDuration: total,
|
||||||
|
words,
|
||||||
|
captionStyle: params.captionStyle,
|
||||||
audioUrl,
|
audioUrl,
|
||||||
wordTimings,
|
|
||||||
musicUrl,
|
musicUrl,
|
||||||
musicVolume: params.musicVolume,
|
musicVolume: params.musicVolume,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
storeName,
|
storeName,
|
||||||
endingFrames,
|
endingSeconds,
|
||||||
};
|
|
||||||
const composition = await selectComposition({ serveUrl, id: "ImageVideo", inputProps });
|
|
||||||
|
|
||||||
const outputFilename = `out-${job.id}.mp4`;
|
|
||||||
const outputLocation = path.join(REMOTION_TEMP_DIR, outputFilename);
|
|
||||||
let lastReported = 25;
|
|
||||||
|
|
||||||
await renderMedia({
|
|
||||||
composition: { ...composition, durationInFrames: totalFrames },
|
|
||||||
serveUrl,
|
|
||||||
codec: "h264",
|
|
||||||
outputLocation,
|
|
||||||
inputProps,
|
|
||||||
chromiumOptions: {
|
|
||||||
disableWebSecurity: true,
|
|
||||||
ignoreCertificateErrors: true,
|
|
||||||
},
|
},
|
||||||
concurrency: 1, // évite d'épuiser la mémoire du conteneur
|
onProgress: (ratio) => {
|
||||||
onProgress: ({ progress: ratio }) => {
|
|
||||||
const pct = 25 + Math.floor(ratio * 70);
|
const pct = 25 + Math.floor(ratio * 70);
|
||||||
if (pct >= lastReported + 5) {
|
if (pct >= lastReported + 5) {
|
||||||
lastReported = pct;
|
lastReported = pct;
|
||||||
@@ -196,16 +87,12 @@ export async function runImagesReelJob({ job, progress }: JobContext): Promise<I
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const thumbnailFilename = outputFilename.replace(".mp4", "-thumb.jpg");
|
const thumbnailPath = outputLocation.replace(/\.mp4$/, "-thumb.jpg");
|
||||||
const thumbnailOk = await generateVideoThumbnail(
|
const thumbnailOk = await generateVideoThumbnail(outputLocation, thumbnailPath, 2);
|
||||||
outputLocation,
|
|
||||||
path.join(REMOTION_TEMP_DIR, thumbnailFilename),
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: `/uploads/temp/${outputFilename}`,
|
url: `/uploads/temp/${path.basename(outputLocation)}`,
|
||||||
thumbnailUrl: thumbnailOk ? `/uploads/temp/${thumbnailFilename}` : null,
|
thumbnailUrl: thumbnailOk ? `/uploads/temp/${path.basename(thumbnailPath)}` : null,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
// Images, musique importée et voix ne servent plus une fois le rendu fini
|
// Images, musique importée et voix ne servent plus une fois le rendu fini
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { toDataUrl } from "./remotionAssets";
|
||||||
|
|
||||||
|
describe("toDataUrl", () => {
|
||||||
|
it("refuse un chemin qui sort du dossier uploads", async () => {
|
||||||
|
await expect(toDataUrl("/uploads/../../etc/passwd")).rejects.toThrow(/refusé/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laisse les URL externes telles quelles", async () => {
|
||||||
|
await expect(toDataUrl("https://exemple.fr/logo.png")).resolves.toBe("https://exemple.fr/logo.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Ressources fournies aux compositions Remotion pendant un rendu serveur.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
/** Dossier temporaire servi sous /uploads/temp (voir server/index.ts). */
|
||||||
|
export const REMOTION_TEMP_DIR =
|
||||||
|
process.env.TEMP_UPLOAD_DIR || path.join(process.cwd(), "uploads", "temp");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL HTTP locale d'un fichier servi par l'application. Vidéo et audio sont
|
||||||
|
* lus côté Node par le moteur de rendu : l'adresse locale suffit.
|
||||||
|
*/
|
||||||
|
export function localHttpUrl(url: string): string {
|
||||||
|
if (!url.startsWith("/")) return url;
|
||||||
|
const port = process.env.PORT || "5555";
|
||||||
|
return `http://localhost:${port}${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL servie d'un fichier du dossier temporaire. */
|
||||||
|
export function tempFileUrl(absolutePath: string): string {
|
||||||
|
const relative = path.relative(REMOTION_TEMP_DIR, absolutePath).split(path.sep).join("/");
|
||||||
|
return localHttpUrl(`/uploads/temp/${relative}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_BY_EXT: Record<string, string> = {
|
||||||
|
png: "image/png",
|
||||||
|
gif: "image/gif",
|
||||||
|
webp: "image/webp",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit un chemin local /uploads/... en data URL : les images sont
|
||||||
|
* affichées par Chromium, qui ne lit pas http://localhost de façon fiable
|
||||||
|
* dans Docker. Refuse tout chemin sortant du dossier uploads.
|
||||||
|
*/
|
||||||
|
export async function toDataUrl(relativeUrl: string): Promise<string> {
|
||||||
|
if (!relativeUrl.startsWith("/uploads/")) return relativeUrl;
|
||||||
|
const uploadsRoot = path.join(process.cwd(), "uploads") + path.sep;
|
||||||
|
const tempRoot = path.resolve(REMOTION_TEMP_DIR) + path.sep;
|
||||||
|
const filePath = relativeUrl.startsWith("/uploads/temp/")
|
||||||
|
? path.resolve(REMOTION_TEMP_DIR, "." + relativeUrl.slice("/uploads/temp".length))
|
||||||
|
: path.resolve(process.cwd(), "." + relativeUrl);
|
||||||
|
if (!filePath.startsWith(uploadsRoot) && !filePath.startsWith(tempRoot)) {
|
||||||
|
throw new Error(`Chemin d'image refusé : ${relativeUrl}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const buffer = await fs.promises.readFile(filePath);
|
||||||
|
const ext = path.extname(filePath).slice(1).toLowerCase();
|
||||||
|
return `data:${MIME_BY_EXT[ext] ?? "image/jpeg"};base64,${buffer.toString("base64")}`;
|
||||||
|
} catch {
|
||||||
|
console.warn(`⚠️ [Remotion] Image introuvable : ${filePath}`);
|
||||||
|
return relativeUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Rendu Remotion côté serveur, commun aux Reels vidéo et images : bundle mis
|
||||||
|
* en cache, réglages d'encodage des réseaux sociaux, progression.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import { bundle } from "@remotion/bundler";
|
||||||
|
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||||||
|
|
||||||
|
let bundleCache: Promise<string> | null = null;
|
||||||
|
|
||||||
|
/** Bundle des compositions, construit une fois par processus. */
|
||||||
|
export function getRemotionBundle(): Promise<string> {
|
||||||
|
if (!bundleCache) {
|
||||||
|
// process.cwd() = /app dans Docker, racine du projet en dev
|
||||||
|
const entryPoint = path.resolve(process.cwd(), "client/src/remotion/index.ts");
|
||||||
|
console.log("📦 [Remotion] Bundle de", entryPoint);
|
||||||
|
bundleCache = bundle({
|
||||||
|
entryPoint,
|
||||||
|
// Mêmes alias que Vite : les compositions importent @shared/*
|
||||||
|
webpackOverride: (config) => ({
|
||||||
|
...config,
|
||||||
|
resolve: {
|
||||||
|
...config.resolve,
|
||||||
|
alias: {
|
||||||
|
...(config.resolve?.alias ?? {}),
|
||||||
|
"@shared": path.resolve(process.cwd(), "shared"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}).catch((error) => {
|
||||||
|
bundleCache = null; // réessayer au prochain rendu
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return bundleCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rendus en parallèle dans Chromium : par défaut la moitié des cœurs, 1 au minimum. */
|
||||||
|
function renderConcurrency(): number {
|
||||||
|
const configured = Number(process.env.RENDER_CONCURRENCY);
|
||||||
|
if (Number.isFinite(configured) && configured > 0) return Math.floor(configured);
|
||||||
|
return Math.max(1, Math.floor(os.cpus().length / 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderReelComposition(options: {
|
||||||
|
compositionId: "ReelVideo" | "ImageVideo";
|
||||||
|
inputProps: Record<string, unknown>;
|
||||||
|
outputLocation: string;
|
||||||
|
onProgress?: (ratio: number) => void;
|
||||||
|
}): Promise<void> {
|
||||||
|
const serveUrl = await getRemotionBundle();
|
||||||
|
const composition = await selectComposition({
|
||||||
|
serveUrl,
|
||||||
|
id: options.compositionId,
|
||||||
|
inputProps: options.inputProps,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await renderMedia({
|
||||||
|
composition,
|
||||||
|
serveUrl,
|
||||||
|
inputProps: options.inputProps,
|
||||||
|
outputLocation: options.outputLocation,
|
||||||
|
codec: "h264",
|
||||||
|
// Qualité visuelle élevée, conforme aux recommandations Instagram/TikTok
|
||||||
|
crf: 18,
|
||||||
|
x264Preset: "medium",
|
||||||
|
pixelFormat: "yuv420p",
|
||||||
|
colorSpace: "bt709",
|
||||||
|
audioCodec: "aac",
|
||||||
|
audioBitrate: "192k",
|
||||||
|
concurrency: renderConcurrency(),
|
||||||
|
onProgress: ({ progress }) => options.onProgress?.(progress),
|
||||||
|
// Une image lente (décodage vidéo sur une petite machine) ne doit pas faire échouer le rendu
|
||||||
|
timeoutInMilliseconds: 60_000,
|
||||||
|
onBrowserLog: (log) => {
|
||||||
|
if (log.type === "error") console.warn("⚠️ [Remotion] Navigateur :", log.text.slice(0, 300));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`🎞️ [Remotion] ${options.compositionId} rendu en ${((Date.now() - startedAt) / 1000).toFixed(1)} s`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* Rendu d'un Reel à partir d'une vidéo : FFmpeg → médiathèque → publication.
|
* Rendu d'un Reel à partir d'une vidéo, puis médiathèque et publication.
|
||||||
* Exécuté par la file `reel_jobs` (voir queue.ts).
|
* Exécuté par la file `reel_jobs` (voir queue.ts).
|
||||||
|
*
|
||||||
|
* Deux moteurs :
|
||||||
|
* - « remotion » (défaut) : le service Python prépare l'image (recadrage, HDR,
|
||||||
|
* stabilisation) et la piste son finale, Remotion compose les sous-titres
|
||||||
|
* animés, le logo et l'effet de fin — le même rendu que l'aperçu ;
|
||||||
|
* - « ffmpeg » : tout est fait par le service Python (sous-titres ASS),
|
||||||
|
* plus rapide, conservé en secours (REEL_RENDERER=ffmpeg).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { videoReelParamsSchema } from "@shared/reel";
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { FADE_SECONDS } from "@shared/captions";
|
||||||
|
import { videoReelParamsSchema, type VideoReelParams } from "@shared/reel";
|
||||||
import { storage } from "../../storage";
|
import { storage } from "../../storage";
|
||||||
import { ffmpegService } from "../ffmpeg";
|
import { ffmpegService, type ReelRenderOptions } from "../ffmpeg";
|
||||||
import { resolveInternalUrl } from "../minio";
|
import { resolveInternalUrl } from "../minio";
|
||||||
import type { JobContext } from "./queue";
|
import type { JobContext } from "./queue";
|
||||||
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl } from "./assets";
|
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl } from "./assets";
|
||||||
import { describePublishFailure, publishReelToPages, storeRenderedVideo } from "./publish";
|
import { describePublishFailure, publishReelToPages, storeRenderedVideo } from "./publish";
|
||||||
|
import { REMOTION_TEMP_DIR, tempFileUrl, toDataUrl } from "./remotionAssets";
|
||||||
|
import { renderReelComposition } from "./remotionRenderer";
|
||||||
|
|
||||||
|
export type ReelRenderer = "remotion" | "ffmpeg";
|
||||||
|
|
||||||
|
export function configuredRenderer(): ReelRenderer {
|
||||||
|
return process.env.REEL_RENDERER === "ffmpeg" ? "ffmpeg" : "remotion";
|
||||||
|
}
|
||||||
|
|
||||||
export async function runVideoReelJob({ job, progress }: JobContext) {
|
export async function runVideoReelJob({ job, progress }: JobContext) {
|
||||||
if (!job.postId) throw new Error("Job vidéo sans post associé");
|
if (!job.postId) throw new Error("Job vidéo sans post associé");
|
||||||
@@ -28,9 +46,7 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
|
|||||||
resolveGeminiApiKey(params.ttsEngine),
|
resolveGeminiApiKey(params.ttsEngine),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await progress(15, "render");
|
const options: ReelRenderOptions = {
|
||||||
const startedAt = Date.now();
|
|
||||||
const rendered = await ffmpegService.renderReel(resolveInternalUrl(media.originalUrl), {
|
|
||||||
text: params.overlayText,
|
text: params.overlayText,
|
||||||
musicUrl,
|
musicUrl,
|
||||||
ttsEnabled: params.ttsEnabled,
|
ttsEnabled: params.ttsEnabled,
|
||||||
@@ -42,18 +58,23 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
|
|||||||
musicVolume: params.musicVolume,
|
musicVolume: params.musicVolume,
|
||||||
drawText: params.drawText,
|
drawText: params.drawText,
|
||||||
stabilize: params.stabilize,
|
stabilize: params.stabilize,
|
||||||
watermarkUrl: logoPath ? resolveInternalUrl(logoPath) : undefined,
|
|
||||||
storeName: params.storeName,
|
storeName: params.storeName,
|
||||||
enableEndingEffect: params.enableEndingEffect,
|
enableEndingEffect: params.enableEndingEffect,
|
||||||
});
|
};
|
||||||
console.log(`⏱️ [Reels] Rendu : ${((Date.now() - startedAt) / 1000).toFixed(1)} s, vidéo de ${rendered.duration.toFixed(1)} s`);
|
|
||||||
|
|
||||||
await progress(65, "store");
|
const renderer = configuredRenderer();
|
||||||
const videoBuffer = rendered.video;
|
const startedAt = Date.now();
|
||||||
|
const videoBuffer =
|
||||||
|
renderer === "remotion"
|
||||||
|
? await renderWithRemotion(job.id, resolveInternalUrl(media.originalUrl), options, params, logoPath, progress)
|
||||||
|
: await renderWithFfmpeg(resolveInternalUrl(media.originalUrl), options, logoPath, progress);
|
||||||
|
console.log(`⏱️ [Reels] Rendu ${renderer} : ${((Date.now() - startedAt) / 1000).toFixed(1)} s`);
|
||||||
|
|
||||||
|
await progress(88, "store");
|
||||||
const processedMedia = await storeRenderedVideo(job.userId, videoBuffer, `reel-${Date.now()}.mp4`);
|
const processedMedia = await storeRenderedVideo(job.userId, videoBuffer, `reel-${Date.now()}.mp4`);
|
||||||
await storage.updatePostMedia(postId, [processedMedia.id]);
|
await storage.updatePostMedia(postId, [processedMedia.id]);
|
||||||
|
|
||||||
await progress(85, "publish");
|
await progress(93, "publish");
|
||||||
const results = await publishReelToPages({
|
const results = await publishReelToPages({
|
||||||
postId,
|
postId,
|
||||||
pageIds: params.pageIds,
|
pageIds: params.pageIds,
|
||||||
@@ -66,5 +87,66 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
|
|||||||
throw new Error(describePublishFailure(results));
|
throw new Error(describePublishFailure(results));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { mediaId: processedMedia.id, results };
|
return { mediaId: processedMedia.id, renderer, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderWithFfmpeg(
|
||||||
|
videoUrl: string,
|
||||||
|
options: ReelRenderOptions,
|
||||||
|
logoPath: string | undefined,
|
||||||
|
progress: JobContext["progress"],
|
||||||
|
): Promise<Buffer> {
|
||||||
|
await progress(15, "render");
|
||||||
|
const rendered = await ffmpegService.renderReel(videoUrl, {
|
||||||
|
...options,
|
||||||
|
watermarkUrl: logoPath ? resolveInternalUrl(logoPath) : undefined,
|
||||||
|
});
|
||||||
|
return rendered.video;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderWithRemotion(
|
||||||
|
jobId: string,
|
||||||
|
videoUrl: string,
|
||||||
|
options: ReelRenderOptions,
|
||||||
|
params: VideoReelParams,
|
||||||
|
logoPath: string | undefined,
|
||||||
|
progress: JobContext["progress"],
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const workdir = path.join(REMOTION_TEMP_DIR, `reel-${jobId}`);
|
||||||
|
try {
|
||||||
|
// 1. Image recadrée à la durée finale, piste son finale, mots minutés
|
||||||
|
await progress(10, "voice");
|
||||||
|
const prepared = await ffmpegService.prepareReel(videoUrl, { ...options, hasLogo: Boolean(logoPath) }, workdir);
|
||||||
|
|
||||||
|
// 2. Composition : sous-titres animés, logo, effet de fin
|
||||||
|
await progress(40, "render");
|
||||||
|
const outputLocation = path.join(workdir, "reel.mp4");
|
||||||
|
let lastReported = 40;
|
||||||
|
await renderReelComposition({
|
||||||
|
compositionId: "ReelVideo",
|
||||||
|
outputLocation,
|
||||||
|
inputProps: {
|
||||||
|
videoUrl: tempFileUrl(prepared.videoPath),
|
||||||
|
videoDuration: prepared.totalDuration,
|
||||||
|
totalDuration: prepared.totalDuration,
|
||||||
|
words: prepared.words,
|
||||||
|
captionStyle: params.captionStyle,
|
||||||
|
logoUrl: logoPath ? await toDataUrl(logoPath) : undefined,
|
||||||
|
storeName: params.enableEndingEffect ? params.storeName : undefined,
|
||||||
|
logoStart: prepared.logoStart,
|
||||||
|
fadeStart: params.enableEndingEffect ? Math.max(0, prepared.totalDuration - FADE_SECONDS) : null,
|
||||||
|
mixedAudioUrl: prepared.audioPath ? tempFileUrl(prepared.audioPath) : undefined,
|
||||||
|
},
|
||||||
|
onProgress: (ratio) => {
|
||||||
|
const pct = 40 + Math.floor(ratio * 45);
|
||||||
|
if (pct >= lastReported + 5) {
|
||||||
|
lastReported = pct;
|
||||||
|
progress(pct, "render").catch(() => { /* non bloquant */ });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return await fs.promises.readFile(outputLocation);
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workdir, { recursive: true, force: true }).catch(() => { /* déjà absent */ });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { estimateVoiceTiming } from "./ttsSync";
|
||||||
|
|
||||||
|
describe("estimateVoiceTiming", () => {
|
||||||
|
it("ignore hashtags et liens dans le décompte", () => {
|
||||||
|
expect(estimateVoiceTiming("Venez vite #promo https://x.fr").wordCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prévient quand la voix sera trop longue", () => {
|
||||||
|
const timing = estimateVoiceTiming("mot ".repeat(200));
|
||||||
|
expect(timing.isHealthy).toBe(false);
|
||||||
|
expect(timing.warnings[0]).toMatch(/Texte long/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
socialPages,
|
socialPages,
|
||||||
media,
|
media,
|
||||||
posts,
|
posts,
|
||||||
|
reelJobs,
|
||||||
postMedia,
|
postMedia,
|
||||||
scheduledPosts,
|
scheduledPosts,
|
||||||
aiGenerations,
|
aiGenerations,
|
||||||
@@ -44,7 +45,7 @@ import {
|
|||||||
type InsertFacebookConfig,
|
type InsertFacebookConfig,
|
||||||
} from "@shared/schema";
|
} from "@shared/schema";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { eq, and, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray, getTableColumns, sql, type SQL } from "drizzle-orm";
|
import { eq, and, or, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray, getTableColumns, sql, type SQL } from "drizzle-orm";
|
||||||
import { encrypt, decrypt, isEncrypted } from "./utils/encryption";
|
import { encrypt, decrypt, isEncrypted } from "./utils/encryption";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,7 +148,7 @@ export interface IStorage {
|
|||||||
getPosts(userId: string): Promise<Post[]>;
|
getPosts(userId: string): Promise<Post[]>;
|
||||||
getPost(id: string): Promise<Post | undefined>;
|
getPost(id: string): Promise<Post | undefined>;
|
||||||
getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>;
|
getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>;
|
||||||
getOngoingReelPosts(userId: string): Promise<Post[]>;
|
getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]>;
|
||||||
createPost(post: InsertPost): Promise<Post>;
|
createPost(post: InsertPost): Promise<Post>;
|
||||||
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
|
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
|
||||||
updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>;
|
updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>;
|
||||||
@@ -551,21 +552,34 @@ export class DatabaseStorage implements IStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reels dont la génération est en cours. Filtré en SQL : la route chargeait
|
* Reels dont la génération est en cours, plus ceux qui ont échoué dans les
|
||||||
* tous les posts de l'utilisateur pour n'en garder qu'une poignée, toutes les
|
* dernières 24 h (pour que l'utilisateur en voie la cause), avec l'étape
|
||||||
* trois secondes.
|
* courante du rendu. Filtré en SQL : la route est interrogée toutes les
|
||||||
|
* trois secondes pendant un rendu.
|
||||||
*/
|
*/
|
||||||
async getOngoingReelPosts(userId: string): Promise<Post[]> {
|
async getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]> {
|
||||||
return await db
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(posts)
|
.from(posts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(posts.userId, userId),
|
eq(posts.userId, userId),
|
||||||
inArray(posts.generationStatus, ['processing', 'pending'])
|
or(
|
||||||
|
inArray(posts.generationStatus, ['processing', 'pending']),
|
||||||
|
and(eq(posts.generationStatus, 'failed'), gte(posts.updatedAt, since), isNotNull(posts.generationError)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(desc(posts.createdAt));
|
.orderBy(desc(posts.createdAt));
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
|
||||||
|
const jobs = await db
|
||||||
|
.select({ postId: reelJobs.postId, step: reelJobs.step })
|
||||||
|
.from(reelJobs)
|
||||||
|
.where(inArray(reelJobs.postId, rows.map((p) => p.id)));
|
||||||
|
const stepByPost = new Map(jobs.map((j) => [j.postId, j.step]));
|
||||||
|
return rows.map((post) => ({ ...post, generationStep: stepByPost.get(post.id) ?? null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI Generations
|
// AI Generations
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
cleanCaptionText,
|
||||||
|
computeImagesTiming,
|
||||||
|
computeReelTiming,
|
||||||
|
groupLines,
|
||||||
|
offsetWords,
|
||||||
|
spreadWords,
|
||||||
|
} from "./captions";
|
||||||
|
|
||||||
|
// Mêmes cas que ffmpeg-service/tests/test_render.py : l'aperçu et le rendu
|
||||||
|
// doivent tomber sur les mêmes durées.
|
||||||
|
describe("computeReelTiming", () => {
|
||||||
|
it("allonge la vidéo quand la voix dépasse", () => {
|
||||||
|
expect(computeReelTiming({ videoDuration: 5, voiceDuration: 10, hasOutro: false, endingEffect: true }).total).toBe(12.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("réserve l'effet de fin après la voix", () => {
|
||||||
|
const timing = computeReelTiming({ videoDuration: 6, voiceDuration: 4.8, hasOutro: true, endingEffect: true });
|
||||||
|
expect(timing.total).toBe(9.3);
|
||||||
|
expect(timing.logoStart).toBeCloseTo(6.8);
|
||||||
|
expect(timing.fadeStart).toBeCloseTo(7.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("n'a ni logo ni fondu sans effet de fin", () => {
|
||||||
|
const timing = computeReelTiming({ videoDuration: 12, hasOutro: true, endingEffect: false });
|
||||||
|
expect(timing).toEqual({ total: 12, logoStart: null, fadeStart: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeImagesTiming", () => {
|
||||||
|
it("garde un Reel d'images entre 25 et 30 s", () => {
|
||||||
|
expect(computeImagesTiming({ imageCount: 3, hasEnding: true })).toEqual({ total: 25, endingSeconds: 3 });
|
||||||
|
expect(computeImagesTiming({ imageCount: 4, voiceDuration: 40, hasEnding: true }).total).toBe(30);
|
||||||
|
expect(computeImagesTiming({ imageCount: 4, voiceDuration: 24, hasEnding: false }).total).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groupLines", () => {
|
||||||
|
const words = (texts: string[]) => texts.map((text, i) => ({ text, start: i, end: i + 0.5 }));
|
||||||
|
|
||||||
|
it("coupe à la ponctuation et limite chaque ligne", () => {
|
||||||
|
const lines = groupLines(words(["Bonjour,", "venez", "découvrir", "nos", "nouveautés"]));
|
||||||
|
expect(lines[0].map((w) => w.text)).toEqual(["Bonjour,"]);
|
||||||
|
expect(lines.every((line) => line.length <= 3)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("texte des sous-titres", () => {
|
||||||
|
it("retire hashtags, emojis et liens", () => {
|
||||||
|
expect(cleanCaptionText("Promo 🌸 #AménagementExtérieur ici https://x.fr !")).toBe("Promo ici !");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("répartit l'estimation sur l'intervalle", () => {
|
||||||
|
const spread = spreadWords("un deux trois", 2, 5);
|
||||||
|
expect(spread[0].start).toBe(2);
|
||||||
|
expect(spread[spread.length - 1].end).toBeCloseTo(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("décale les mots de la voix vers le temps de la vidéo", () => {
|
||||||
|
expect(offsetWords([{ text: "a", start: 0.1, end: 0.4 }], 2)).toEqual([{ text: "a", start: 2.1, end: 2.4 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Sous-titres et minutage des Reels, partagés entre l'aperçu (navigateur),
|
||||||
|
* le rendu Remotion (serveur) et les tests.
|
||||||
|
* Les règles de durée reprennent celles du service Python (app/render.py).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CAPTION_STYLES = ["bold", "neon", "minimal"] as const;
|
||||||
|
export type CaptionStyle = (typeof CAPTION_STYLES)[number];
|
||||||
|
|
||||||
|
export const CAPTION_STYLE_OPTIONS: { id: CaptionStyle; label: string; description: string }[] = [
|
||||||
|
{ id: "bold", label: "Impact", description: "Majuscules, mot prononcé en jaune" },
|
||||||
|
{ id: "neon", label: "Surligné", description: "Mot prononcé sur fond coloré" },
|
||||||
|
{ id: "minimal", label: "Épuré", description: "Texte qui se révèle au fil de la voix" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_CAPTION_STYLE: CaptionStyle = "bold";
|
||||||
|
|
||||||
|
/** Mot affiché et son intervalle, en secondes depuis le début de la vidéo. */
|
||||||
|
export interface TimedWord {
|
||||||
|
text: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REEL_FPS = 30;
|
||||||
|
export const REEL_WIDTH = 1080;
|
||||||
|
export const REEL_HEIGHT = 1920;
|
||||||
|
|
||||||
|
/** La voix démarre après ce délai (le temps de capter l'attention). */
|
||||||
|
export const VOICE_DELAY = 2;
|
||||||
|
const VOICE_TAIL = 0.8;
|
||||||
|
const OUTRO_MIN = 2.5;
|
||||||
|
const LOGO_SECONDS = 5;
|
||||||
|
export const FADE_SECONDS = 2;
|
||||||
|
|
||||||
|
export interface ReelTiming {
|
||||||
|
total: number;
|
||||||
|
/** Début de l'effet de fin (grand logo), ou null sans effet de fin. */
|
||||||
|
logoStart: number | null;
|
||||||
|
fadeStart: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Durée finale : la vidéo s'allonge (dernière image figée) si la voix dépasse. */
|
||||||
|
export function computeReelTiming(input: {
|
||||||
|
videoDuration: number;
|
||||||
|
voiceDuration?: number;
|
||||||
|
hasOutro: boolean;
|
||||||
|
endingEffect: boolean;
|
||||||
|
}): ReelTiming {
|
||||||
|
const hasOutro = input.hasOutro && input.endingEffect;
|
||||||
|
const speechEnd = input.voiceDuration ? VOICE_DELAY + input.voiceDuration : 0;
|
||||||
|
let total = input.videoDuration;
|
||||||
|
if (input.voiceDuration) {
|
||||||
|
total = Math.max(total, speechEnd + VOICE_TAIL);
|
||||||
|
if (hasOutro) total = Math.max(total, speechEnd + OUTRO_MIN);
|
||||||
|
}
|
||||||
|
total = Math.round(total * 1000) / 1000;
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
logoStart: hasOutro ? Math.max(0, total - LOGO_SECONDS, speechEnd) : null,
|
||||||
|
fadeStart: input.endingEffect ? Math.max(0, total - FADE_SECONDS) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_WORDS_PER_LINE = 3;
|
||||||
|
const MAX_CHARS_PER_LINE = 18;
|
||||||
|
const LINE_BREAK = /[.!?,:;…]$/;
|
||||||
|
|
||||||
|
/** Découpe en lignes courtes, coupées de préférence à la ponctuation. */
|
||||||
|
export function groupLines(words: TimedWord[]): TimedWord[][] {
|
||||||
|
const lines: TimedWord[][] = [];
|
||||||
|
let current: TimedWord[] = [];
|
||||||
|
for (const word of words) {
|
||||||
|
const chars = current.reduce((n, w) => n + w.text.length + 1, 0) + word.text.length;
|
||||||
|
if (current.length && (current.length >= MAX_WORDS_PER_LINE || chars > MAX_CHARS_PER_LINE)) {
|
||||||
|
lines.push(current);
|
||||||
|
current = [];
|
||||||
|
}
|
||||||
|
current.push(word);
|
||||||
|
if (LINE_BREAK.test(word.text)) {
|
||||||
|
lines.push(current);
|
||||||
|
current = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.length) lines.push(current);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retire emojis, hashtags et liens : ce qui n'est ni lu ni affiché. */
|
||||||
|
export function cleanCaptionText(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/https?:\/\/\S+/g, " ")
|
||||||
|
.replace(/#[\wÀ-ÿ]+/g, " ")
|
||||||
|
.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, " ")
|
||||||
|
.replace(/[☀-➿⬀-⯿️]/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Répartit un texte sur un intervalle au prorata de la longueur des mots.
|
||||||
|
* Sert d'aperçu tant que la voix n'a pas été générée.
|
||||||
|
*/
|
||||||
|
export function spreadWords(text: string, start: number, end: number): TimedWord[] {
|
||||||
|
const tokens = cleanCaptionText(text).split(" ").filter(Boolean);
|
||||||
|
if (!tokens.length || end <= start) return [];
|
||||||
|
const weights = tokens.map((t) => Math.max(1, t.replace(/[^\wÀ-ÿ]/g, "").length));
|
||||||
|
const totalWeight = weights.reduce((a, b) => a + b, 0);
|
||||||
|
let cursor = start;
|
||||||
|
return tokens.map((text, i) => {
|
||||||
|
const duration = ((end - start) * weights[i]) / totalWeight;
|
||||||
|
const word = { text, start: cursor, end: cursor + duration };
|
||||||
|
cursor += duration;
|
||||||
|
return word;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Décale des mots minutés depuis le début de la voix vers le temps de la vidéo. */
|
||||||
|
export function offsetWords(words: TimedWord[], offset: number): TimedWord[] {
|
||||||
|
return words.map((w) => ({ ...w, start: w.start + offset, end: w.end + offset }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGES_ENDING_SECONDS = 3;
|
||||||
|
const IMAGES_MIN_CONTENT = 22;
|
||||||
|
const IMAGES_MAX_CONTENT = 27;
|
||||||
|
const SECONDS_PER_IMAGE = 3;
|
||||||
|
|
||||||
|
/** Reel d'images : 25 à 30 s, diapositive de fin comprise. */
|
||||||
|
export function computeImagesTiming(input: { imageCount: number; voiceDuration?: number; hasEnding: boolean }): {
|
||||||
|
total: number;
|
||||||
|
endingSeconds: number;
|
||||||
|
} {
|
||||||
|
const natural = Math.max((input.voiceDuration ?? 0) + 1, input.imageCount * SECONDS_PER_IMAGE);
|
||||||
|
const content = Math.min(Math.max(natural, IMAGES_MIN_CONTENT), IMAGES_MAX_CONTENT);
|
||||||
|
const endingSeconds = input.hasEnding ? IMAGES_ENDING_SECONDS : 0;
|
||||||
|
return { total: content + endingSeconds, endingSeconds };
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { imagesReelParamsSchema, ttsPreviewSchema, videoReelParamsSchema } from "./reel";
|
||||||
|
|
||||||
|
describe("videoReelParamsSchema", () => {
|
||||||
|
it("exige une vidéo et une page, avec des messages lisibles", () => {
|
||||||
|
const result = videoReelParamsSchema.safeParse({ pageIds: [] });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error?.issues.map((i) => i.message)).toContain("Vidéo requise");
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepte les anciens paramètres encore stockés dans des jobs", () => {
|
||||||
|
expect(videoReelParamsSchema.safeParse({ videoMediaId: "v", pageIds: ["p"], wordDuration: 0.6 }).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuse un moteur de voix inconnu", () => {
|
||||||
|
expect(videoReelParamsSchema.safeParse({ videoMediaId: "v", pageIds: ["p"], ttsEngine: "autre" }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("imagesReelParamsSchema", () => {
|
||||||
|
it("limite à 4 images", () => {
|
||||||
|
expect(imagesReelParamsSchema.safeParse({ imageUrls: ["a", "b", "c", "d", "e"] }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ttsPreviewSchema", () => {
|
||||||
|
it("refuse un texte vide", () => {
|
||||||
|
expect(ttsPreviewSchema.safeParse({ text: " " }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { CAPTION_STYLES, DEFAULT_CAPTION_STYLE } from "./captions";
|
||||||
import { TTS_ENGINES, TTS_STYLES } from "./voices";
|
import { TTS_ENGINES, TTS_STYLES } from "./voices";
|
||||||
|
|
||||||
const optionalText = z
|
const optionalText = z
|
||||||
@@ -33,6 +34,7 @@ export const videoReelParamsSchema = z.object({
|
|||||||
fontSize: z.number().int().min(16).max(200).default(64),
|
fontSize: z.number().int().min(16).max(200).default(64),
|
||||||
musicVolume: z.number().min(0).max(2).default(0.25),
|
musicVolume: z.number().min(0).max(2).default(0.25),
|
||||||
drawText: z.boolean().default(true),
|
drawText: z.boolean().default(true),
|
||||||
|
captionStyle: z.enum(CAPTION_STYLES).default(DEFAULT_CAPTION_STYLE),
|
||||||
stabilize: z.boolean().default(false),
|
stabilize: z.boolean().default(false),
|
||||||
enableEndingEffect: z.boolean().default(true),
|
enableEndingEffect: z.boolean().default(true),
|
||||||
// Déterminé par le serveur à partir de la première page, jamais par le client
|
// Déterminé par le serveur à partir de la première page, jamais par le client
|
||||||
@@ -52,6 +54,7 @@ export const imagesReelParamsSchema = z.object({
|
|||||||
ttsEngine: z.enum(TTS_ENGINES).optional(),
|
ttsEngine: z.enum(TTS_ENGINES).optional(),
|
||||||
ttsVoice: optionalText,
|
ttsVoice: optionalText,
|
||||||
ttsStyle: z.enum(TTS_STYLES).optional(),
|
ttsStyle: z.enum(TTS_STYLES).optional(),
|
||||||
|
captionStyle: z.enum(CAPTION_STYLES).default(DEFAULT_CAPTION_STYLE),
|
||||||
storeName: z.string().optional(),
|
storeName: z.string().optional(),
|
||||||
// Fichiers temporaires à supprimer une fois le rendu terminé
|
// Fichiers temporaires à supprimer une fois le rendu terminé
|
||||||
tempFiles: z.array(z.string()).default([]),
|
tempFiles: z.array(z.string()).default([]),
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.6 KiB |
@@ -1,12 +0,0 @@
|
|||||||
import { bundle } from "@remotion/bundler";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
try {
|
|
||||||
const loc = await bundle({ entryPoint: path.resolve("client/src/remotion/index.ts") });
|
|
||||||
console.log("Success:", loc);
|
|
||||||
} catch(e) {
|
|
||||||
console.error("Error bundling", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
run();
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { bundle } from "@remotion/bundler";
|
|
||||||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
try {
|
|
||||||
const loc = await bundle({ entryPoint: path.resolve("client/src/remotion/index.ts") });
|
|
||||||
console.log("Bundle success:", loc);
|
|
||||||
const comp = await selectComposition({
|
|
||||||
serveUrl: loc,
|
|
||||||
id: "ImageVideo",
|
|
||||||
inputProps: { images: ["https://placehold.co/1080x1920?text=Image+1"] }
|
|
||||||
});
|
|
||||||
console.log("Comp:", comp?.id);
|
|
||||||
await renderMedia({
|
|
||||||
composition: comp,
|
|
||||||
serveUrl: loc,
|
|
||||||
codec: "h264",
|
|
||||||
outputLocation: path.resolve("out.mp4"),
|
|
||||||
inputProps: { images: ["https://placehold.co/1080x1920?text=Image+1"] }
|
|
||||||
});
|
|
||||||
console.log("Render success!");
|
|
||||||
} catch(e) {
|
|
||||||
console.error("Error rendering", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
run();
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@shared": path.resolve(import.meta.dirname, "shared"),
|
||||||
|
"@": path.resolve(import.meta.dirname, "client", "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
include: ["shared/**/*.test.ts", "server/**/*.test.ts"],
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
|
});
|
||||||