diff --git a/.env.example b/.env.example index 7dffdb9..0dad08e 100644 --- a/.env.example +++ b/.env.example @@ -124,3 +124,13 @@ GEMINI_API_KEY= # (tiny, base, small). Choisi à la construction de l'image : changer la valeur # demande de reconstruire ffmpeg-api. # 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= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ad92c6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/DOCKER.md b/DOCKER.md index 5584015..9c52bb6 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -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). Sans clé, la voix Edge gratuite est utilisée et un avertissement apparaît 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` affichent `[ReelQueue] Worker démarré`, et `socialflow-ffmpeg` répond `{"status":"ok","version":2}` sur `/health`. diff --git a/Dockerfile b/Dockerfile index 2d24c99..cb9a2cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -70,7 +70,7 @@ RUN rm -rf client/node_modules \ /tmp/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) RUN npx remotion browser ensure && echo "✓ Remotion browser prêt" diff --git a/after-login.png b/after-login.png deleted file mode 100644 index 0dc38eb..0000000 Binary files a/after-login.png and /dev/null differ diff --git a/after_load.png b/after_load.png deleted file mode 100644 index eefae9a..0000000 Binary files a/after_load.png and /dev/null differ diff --git a/after_navigate_media.png b/after_navigate_media.png deleted file mode 100644 index 8242c8e..0000000 Binary files a/after_navigate_media.png and /dev/null differ diff --git a/build_error.txt b/build_error.txt deleted file mode 100644 index d79d64f..0000000 Binary files a/build_error.txt and /dev/null differ diff --git a/client/src/components/ongoing-reels.tsx b/client/src/components/ongoing-reels.tsx index 8545732..f90b972 100644 --- a/client/src/components/ongoing-reels.tsx +++ b/client/src/components/ongoing-reels.tsx @@ -4,16 +4,20 @@ import { Progress } from "@/components/ui/progress"; import { Clapperboard, Loader2, CheckCircle2, XCircle } from "lucide-react"; import type { Post } from "@shared/schema"; -/** Labels for each progress stage */ -function getStageLabel(progress: number, status?: string): string { - if (status === 'pending') return "En attente de traitement..."; - if (progress <= 5) return "Démarrage du traitement…"; - if (progress <= 15) return "Traitement vidéo (FFmpeg)…"; - if (progress <= 65) return "Encodage en cours…"; - if (progress <= 85) return "Upload vers le cloud…"; - if (progress <= 90) return "Liaison du média…"; - if (progress <= 95) return "Publication en cours…"; - return "Finalisation…"; +type OngoingReel = Post & { generationStep?: string | null }; + +/** Libellé de l'étape en cours, telle que rapportée par la file de rendu. */ +const STEP_LABELS: Record = { + prepare: "Préparation…", + voice: "Voix et préparation de la vidéo…", + render: "Montage : sous-titres, logo, effets…", + store: "Enregistrement dans la médiathèque…", + publish: "Publication…", +}; + +function getStageLabel(post: OngoingReel): string { + if (post.generationStatus === "pending") return "En attente : un autre Reel est en cours…"; + return (post.generationStep && STEP_LABELS[post.generationStep]) || "Démarrage du traitement…"; } interface OngoingReelsProps { @@ -22,12 +26,13 @@ interface OngoingReelsProps { } export default function OngoingReels({ compact = false }: OngoingReelsProps) { - const { data: ongoingPosts = [] } = useQuery({ + const { data: ongoingPosts = [] } = useQuery({ queryKey: ["/api/reels/ongoing"], // Cette route ne renvoie que les générations en cours : tant qu'elle est // vide il n'y a pas de barre de progression à animer. On garde une veille // lente, qui suffit à détecter une génération lancée depuis un autre écran. - refetchInterval: (query) => ((query.state.data?.length ?? 0) > 0 ? 3000 : 30000), + refetchInterval: (query) => + (query.state.data ?? []).some((p) => p.generationStatus !== "failed") ? 3000 : 30000, }); // Nothing to show — render nothing (not even a card) @@ -43,8 +48,12 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) { > - - Génération en cours + {ongoingPosts.some((p) => p.generationStatus !== "failed") ? ( + + ) : ( + + )} + Reels en cours @@ -73,8 +82,8 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) { {isFailed ? (
- - {post.generationError || "Erreur lors du traitement"} + + Échec : {post.generationError || "erreur lors du traitement"}
) : ( @@ -87,7 +96,7 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
- {getStageLabel(progress)} + {getStageLabel(post)} {progress}% diff --git a/client/src/components/reels/caption-style-picker.tsx b/client/src/components/reels/caption-style-picker.tsx new file mode 100644 index 0000000..7b6162e --- /dev/null +++ b/client/src/components/reels/caption-style-picker.tsx @@ -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 = { + bold: ( + + Nos + promos + + ), + neon: ( + + Nos{" "} + promos + + ), + minimal: ( + + Nos + promos + + ), +}; + +export function CaptionStylePicker({ + value, + onChange, + compact = false, +}: { + value: CaptionStyle; + onChange: (style: CaptionStyle) => void; + compact?: boolean; +}) { + return ( +
+ {CAPTION_STYLE_OPTIONS.map((option) => ( + + ))} +
+ ); +} diff --git a/client/src/components/reels/reel-preview.tsx b/client/src/components/reels/reel-preview.tsx new file mode 100644 index 0000000..02c41fb --- /dev/null +++ b/client/src/components/reels/reel-preview.tsx @@ -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(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 ( +
+ +
+ ); + } + + return ( +
+
+ >} + inputProps={composition.inputProps as unknown as Record} + 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 + /> +
+

+ + {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`} +

+
+ ); +} diff --git a/client/src/components/reels/voice-picker.tsx b/client/src/components/reels/voice-picker.tsx index 4540054..c58f57e 100644 --- a/client/src/components/reels/voice-picker.tsx +++ b/client/src/components/reels/voice-picker.tsx @@ -20,6 +20,7 @@ import { type TtsEngine, type TtsStyle, } from "@shared/voices"; +import type { TimedWord } from "@shared/captions"; export interface VoiceSettings { engine: TtsEngine; @@ -27,12 +28,38 @@ export interface VoiceSettings { style: TtsStyle; } +/** Voix générée par « Tester la voix », réutilisée par l'aperçu vidéo. */ +export interface VoicePreviewResult { + audioUrl: string; + duration: number; + words: TimedWord[]; + /** Texte et réglages ayant servi : l'aperçu n'est valable que s'ils n'ont pas changé. */ + text: string; + settings: VoiceSettings; +} + +export function isVoicePreviewCurrent( + preview: VoicePreviewResult | null, + text: string, + settings: VoiceSettings, +): preview is VoicePreviewResult { + return ( + !!preview && + preview.text === text.trim() && + preview.settings.engine === settings.engine && + preview.settings.voice === settings.voice && + preview.settings.style === settings.style + ); +} + interface VoicePickerProps { value: VoiceSettings; onChange: (value: VoiceSettings) => void; /** Texte lu par le bouton « Tester la voix ». */ sampleText?: string; compact?: boolean; + /** Appelé avec la voix générée (audio et minutage des mots). */ + onPreview?: (result: VoicePreviewResult) => void; } const FALLBACK_SAMPLE = "Découvrez nos nouveautés en magasin, on vous attend !"; @@ -41,7 +68,7 @@ const FALLBACK_SAMPLE = "Découvrez nos nouveautés en magasin, on vous attend ! * Choix du moteur, de la voix et du ton, avec écoute de l'aperçu. * Partagé par les pages Reel (vidéo et images, bureau et mobile). */ -export function VoicePicker({ value, onChange, sampleText, compact = false }: VoicePickerProps) { +export function VoicePicker({ value, onChange, sampleText, compact = false, onPreview }: VoicePickerProps) { const { toast } = useToast(); const [loading, setLoading] = useState(false); const [playing, setPlaying] = useState(false); @@ -68,8 +95,9 @@ export function VoicePicker({ value, onChange, sampleText, compact = false }: Vo if (playing) return stop(); setLoading(true); try { + const text = sampleText?.trim() || FALLBACK_SAMPLE; const response = await apiRequest("POST", "/api/reels/tts-preview", { - text: sampleText?.trim() || FALLBACK_SAMPLE, + text, ttsEngine: value.engine, ttsVoice: value.voice, ttsStyle: value.style, @@ -78,7 +106,11 @@ export function VoicePicker({ value, onChange, sampleText, compact = false }: Vo for (const warning of data.warnings ?? []) { toast({ title: "Voix de secours utilisée", description: warning }); } - const audio = new Audio(`data:audio/mpeg;base64,${data.audioBase64}`); + const audioUrl = `data:audio/mpeg;base64,${data.audioBase64}`; + if (sampleText?.trim()) { + onPreview?.({ audioUrl, duration: data.duration, words: data.words ?? [], text, settings: value }); + } + const audio = new Audio(audioUrl); audioRef.current?.pause(); audioRef.current = audio; audio.onended = () => setPlaying(false); diff --git a/client/src/pages/mobile/new-reel.tsx b/client/src/pages/mobile/new-reel.tsx index c90bf50..f54ec35 100644 --- a/client/src/pages/mobile/new-reel.tsx +++ b/client/src/pages/mobile/new-reel.tsx @@ -16,7 +16,10 @@ import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Slider } from "@/components/ui/slider"; import { useToast } from "@/hooks/use-toast"; -import { VoicePicker, type VoiceSettings } from "@/components/reels/voice-picker"; +import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker"; +import { CaptionStylePicker } from "@/components/reels/caption-style-picker"; +import { ReelPreview } from "@/components/reels/reel-preview"; +import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions"; import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import type { SocialPage, Media } from "@shared/schema"; @@ -67,6 +70,10 @@ export default function MobileNewReel() { style: DEFAULT_TTS_STYLE, }); const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings; + const [captionStyle, setCaptionStyle] = useState(DEFAULT_CAPTION_STYLE); + const [voicePreview, setVoicePreview] = useState(null); + const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null; + const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] }); // État audio preview const [isPlaying, setIsPlaying] = useState(null); @@ -203,8 +210,10 @@ export default function MobileNewReel() { onSuccess: async (data) => { await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' }); toast({ - title: data.success ? "Reel créé !" : "Attention", - description: data.success ? "Publication réussie" : "Certaines erreurs", + title: "Création du Reel lancée", + description: data.queued + ? "Un autre Reel est en cours : le vôtre démarrera juste après." + : "Suivez l'avancement sur l'accueil.", }); navigate('/'); }, @@ -264,6 +273,7 @@ export default function MobileNewReel() { ttsEngine, ttsVoice, ttsStyle, + captionStyle, enableEndingEffect, }); }; @@ -456,6 +466,10 @@ export default function MobileNewReel() { className="text-lg" rows={3} /> +
+ + +
@@ -524,6 +539,31 @@ export default function MobileNewReel() { {/* PUBLISH STEP */} {currentStep === 'publish' && (
+ {selectedVideo && ( + + + Aperçu + + +
+ p.id === selectedPages[0])?.pageName} + endingEffect={enableEndingEffect} + /> +
+
+
+ )} Diffusion diff --git a/client/src/pages/mobile/remotion-video.tsx b/client/src/pages/mobile/remotion-video.tsx index b27526f..b0e5e2c 100644 --- a/client/src/pages/mobile/remotion-video.tsx +++ b/client/src/pages/mobile/remotion-video.tsx @@ -1,6 +1,9 @@ -import { useState, useRef } from "react"; +import { useEffect, useMemo, useState, useRef } from "react"; import { useToast } from "@/hooks/use-toast"; -import { VoicePicker, type VoiceSettings } from "@/components/reels/voice-picker"; +import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker"; +import { CaptionStylePicker } from "@/components/reels/caption-style-picker"; +import { ReelPreview } from "@/components/reels/reel-preview"; +import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions"; import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -50,6 +53,11 @@ export default function MobileRemotionVideoPage() { const [scheduledDate, setScheduledDate] = useState(undefined); const [publishDescription, setPublishDescription] = useState(""); const [isPublishing, setIsPublishing] = useState(false); + const [captionStyle, setCaptionStyle] = useState(DEFAULT_CAPTION_STYLE); + const [voicePreview, setVoicePreview] = useState(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(null); const cameraInputRef = useRef(null); const galleryInputRef = useRef(null); @@ -59,6 +67,13 @@ export default function MobileRemotionVideoPage() { const { data: audioTracks = [], isLoading: tracksLoading } = useQuery({ queryKey: ['/api/audio-tracks'] }); const { data: socialPages = [] } = useQuery({ queryKey: ['/api/pages'] }); + // Aperçu : URL locales des fichiers choisis, libérées quand ils changent + const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]); + useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]); + const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]); + useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]); + const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls]; + const facebookPages = socialPages.filter(p => p.platform === 'facebook'); const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok'); @@ -142,6 +157,7 @@ export default function MobileRemotionVideoPage() { const handleGenerate = async () => { if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; } setIsRendering(true); + setRenderProgress(0); setVideoUrl(null); try { const formData = new FormData(); @@ -152,6 +168,7 @@ export default function MobileRemotionVideoPage() { formData.append("ttsEngine", voiceSettings.engine); formData.append("ttsVoice", voiceSettings.voice); formData.append("ttsStyle", voiceSettings.style); + formData.append("captionStyle", captionStyle); if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]); if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); } else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); } @@ -180,6 +197,7 @@ export default function MobileRemotionVideoPage() { toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" }); setIsRendering(false); } else { + setRenderProgress(job.progress ?? 0); setTimeout(checkStatus, 3000); } } catch (e: any) { @@ -328,6 +346,10 @@ export default function MobileRemotionVideoPage() { )}