- {sidebarOpen && (
-
setSidebarOpen(false)} />
- )}
-
- setSidebarOpen(false)} />
-
-
-
- setSidebarOpen(!sidebarOpen)} />
-
-
-
-
-
-
- );
-}
diff --git a/client/src/remotion/ImageComposition.tsx b/client/src/remotion/ImageComposition.tsx
deleted file mode 100644
index 4c4616f..0000000
--- a/client/src/remotion/ImageComposition.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import React from "react";
-import { AbsoluteFill, Html5Audio, Img, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
-import type { CaptionStyle, TimedWord } from "@shared/captions";
-import { Captions } from "./components/Captions";
-import { Outro, Watermark } from "./components/Branding";
-
-export type ImageCompositionProps = {
- images: 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;
- musicUrl?: string;
- musicVolume?: number;
- logoUrl?: string;
- /** Petit logo affiché pendant la vidéo (le grand logo de fin reste sur la diapositive de fin). */
- showWatermark?: boolean;
- storeName?: string;
- /** Durée de la diapositive de fin (logo + nom du magasin), en secondes. */
- endingSeconds?: number;
-};
-
-/**
- * Ken Burns presets — deterministic per image index.
- * Each entry: starting transform → ending transform over the slide duration.
- * Scale > 1 to avoid black edges when translating.
- */
-const KB_PRESETS = [
- // zoom in, center
- { fromScale: 1.0, toScale: 1.14, fromX: "0%", toX: "0%", fromY: "0%", toY: "0%" },
- // zoom in + drift right
- { fromScale: 1.06, toScale: 1.18, fromX: "-3%", toX: "3%", fromY: "0%", toY: "0%" },
- // zoom in + drift left
- { fromScale: 1.06, toScale: 1.18, fromX: "3%", toX: "-3%", fromY: "0%", toY: "0%" },
- // zoom out, center
- { fromScale: 1.16, toScale: 1.04, fromX: "0%", toX: "0%", fromY: "0%", toY: "0%" },
- // pan up + slight zoom
- { fromScale: 1.1, toScale: 1.14, fromX: "0%", toX: "0%", fromY: "3%", toY: "-3%" },
- // pan down + zoom in
- { fromScale: 1.06, toScale: 1.16, fromX: "0%", toX: "0%", fromY: "-3%", toY: "3%" },
- // diagonal drift bottom-right
- { fromScale: 1.08, toScale: 1.18, fromX: "-2%", toX: "2%", fromY: "-2%", toY: "2%" },
- // diagonal drift top-left + zoom out
- { fromScale: 1.18, toScale: 1.06, fromX: "2%", toX: "-2%", fromY: "2%", toY: "-2%" },
-];
-
-/**
- * ImageSlide with Ken Burns effect (slow zoom / pan).
- * The effect index is derived from the image index for deterministic behaviour in Remotion.
- */
-const ImageSlide: React.FC<{ src: string; effectIndex: number }> = ({ src, effectIndex }) => {
- const frame = useCurrentFrame();
- const { durationInFrames } = useVideoConfig();
-
- const preset = KB_PRESETS[effectIndex % KB_PRESETS.length];
- // progress: 0 → 1 over the slide duration, using easeInOut via interpolate extrapolate clamp
- const progress = interpolate(frame, [0, durationInFrames], [0, 1], { extrapolateRight: "clamp" });
-
- const scale = interpolate(progress, [0, 1], [preset.fromScale, preset.toScale]);
- const transX = interpolate(progress, [0, 1], [parseFloat(preset.fromX), parseFloat(preset.toX)]);
- const transY = interpolate(progress, [0, 1], [parseFloat(preset.fromY), parseFloat(preset.toY)]);
-
- return (
-
-
-
- );
-};
-
-/** Reel à partir d'images : diaporama animé, voix, sous-titres et diapositive de fin. */
-export const ImageComposition: React.FC
= ({
- images, words, captionStyle, audioUrl, musicUrl, musicVolume = 0.3, logoUrl, showWatermark = true, storeName, endingSeconds = 3,
-}) => {
- const { fps, durationInFrames } = useVideoConfig();
- const hasEnding = Boolean(logoUrl || storeName) && endingSeconds > 0;
- const endingFrames = hasEnding ? Math.round(endingSeconds * fps) : 0;
- const contentFrames = durationInFrames - endingFrames;
- const perImage = Math.floor(contentFrames / Math.max(images.length, 1));
- const endingStart = contentFrames / fps;
-
- return (
-
- {images.map((imgUrl, index) => (
-
-
-
- ))}
-
- {audioUrl && }
- {musicUrl && (
-
- (words.some((w) => frame / fps >= w.start - 0.2 && frame / fps <= w.end + 0.3)
- ? musicVolume * 0.35
- : musicVolume) *
- interpolate(frame, [durationInFrames - fps, durationInFrames], [1, 0], {
- extrapolateLeft: "clamp",
- extrapolateRight: "clamp",
- })
- }
- />
- )}
-
-
- {logoUrl && showWatermark && }
- {hasEnding && }
-
- );
-};
diff --git a/client/src/remotion/Root.tsx b/client/src/remotion/Root.tsx
index e0d311f..ff5aedb 100644
--- a/client/src/remotion/Root.tsx
+++ b/client/src/remotion/Root.tsx
@@ -1,6 +1,5 @@
import { Composition, type CalculateMetadataFunction } from "remotion";
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. */
@@ -27,21 +26,6 @@ export const RemotionRoot: React.FC = () => {
captionStyle: DEFAULT_CAPTION_STYLE,
} as ReelVideoProps}
/>
- ()}
- defaultProps={{
- images: ["https://placehold.co/1080x1920?text=Image+1"],
- totalDuration: 25,
- words: [],
- captionStyle: DEFAULT_CAPTION_STYLE,
- } as ImageCompositionProps}
- />
>
);
};
diff --git a/server/routes.ts b/server/routes.ts
index 42e0a75..8979701 100644
--- a/server/routes.ts
+++ b/server/routes.ts
@@ -16,7 +16,6 @@ import type { User, InsertUser, ScheduledPost } from "@shared/schema";
import { analyticsRouter } from "./routes/analytics";
import { normalizeQwenUrl } from "./services/reels/assets";
import { reelsRouter } from "./routes/reels";
-import { remotionRouter } from "./routes/remotion";
import { externalRouter } from "./routes/external";
import { tiktokRouter } from "./routes/tiktok";
import { facebookRouter } from "./routes/facebook";
@@ -554,7 +553,6 @@ export async function registerRoutes(app: Express): Promise {
// Reels & Music Routes
app.use("/api", requireAuth, reelsRouter);
- app.use("/api/remotion", requireAuth, remotionRouter);
// Route SQL (réservée aux admins) - DÉSACTIVÉE EN PRODUCTION sauf si explicitement autorisée
diff --git a/server/routes/remotion.ts b/server/routes/remotion.ts
deleted file mode 100644
index 2be53f7..0000000
--- a/server/routes/remotion.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-import { Router } from "express";
-import multer from "multer";
-import path from "path";
-import fs from "fs";
-import type { User } from "@shared/schema";
-import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
-import { storage as dbStorage } from "../storage";
-import { enqueueReelJob, getReelJob } from "../services/reels/queue";
-import { REMOTION_TEMP_DIR } from "../services/reels/imagesPipeline";
-import { resolveStoreName } from "../services/reels/assets";
-import { publishReelToPages, storeRenderedVideo } from "../services/reels/publish";
-
-export const remotionRouter = Router();
-
-const uploadDir = REMOTION_TEMP_DIR;
-if (!fs.existsSync(uploadDir)) {
- fs.mkdirSync(uploadDir, { recursive: true });
-}
-
-const upload = multer({
- storage: multer.diskStorage({
- destination: uploadDir,
- filename: (_req, file, cb) => {
- const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
- cb(null, `remotion-${uniqueSuffix}${path.extname(file.originalname)}`);
- },
- }),
-});
-
-/**
- * GET /api/remotion/render/status/:jobId
- * État d'un rendu, lu dans la file persistante (survit à un redémarrage).
- */
-remotionRouter.get("/render/status/:jobId", async (req, res) => {
- const user = req.user as User;
- const job = await getReelJob(req.params.jobId);
- if (!job || job.kind !== "images" || (job.userId !== user.id && user.role !== "admin")) {
- return res.status(404).json({ error: "Job introuvable" });
- }
-
- if (job.status === "completed") {
- const result = job.result as ImagesReelResult;
- return res.json({ status: "done", url: result.url, thumbnailUrl: result.thumbnailUrl });
- }
- if (job.status === "failed") {
- return res.json({ status: "error", error: "Erreur lors du rendu: " + (job.error ?? "inconnue") });
- }
- res.json({ status: "processing", progress: job.progress, step: job.step, queued: job.status === "pending" });
-});
-
-/**
- * POST /api/remotion/render
- * Met en file le rendu d'un Reel à partir d'images (1 à 4).
- */
-remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { name: "music", maxCount: 1 }]), async (req, res) => {
- const fields = (req.files ?? {}) as Record;
- const uploaded = [...(fields["images"] ?? []), ...(fields["music"] ?? [])];
-
- try {
- const user = req.user as User;
- const existing = req.body.existingImageUrls;
- const libraryUrls: string[] = existing ? (Array.isArray(existing) ? existing : [existing]) : [];
- const uploadedImageUrls = (fields["images"] ?? []).map((f) => `/uploads/temp/${path.basename(f.path)}`);
-
- const musicFile = fields["music"]?.[0];
- const musicVolume = parseFloat(req.body.musicVolume ?? "0.3");
-
- const parsed = imagesReelParamsSchema.safeParse({
- imageUrls: [...libraryUrls, ...uploadedImageUrls],
- overlayText: req.body.overlayText,
- musicUrl: musicFile ? `/uploads/temp/${path.basename(musicFile.path)}` : req.body.musicTrackUrl,
- musicVolume: Number.isFinite(musicVolume) ? musicVolume : undefined,
- ttsEnabled: req.body.ttsEnabled !== "false",
- ttsEngine: req.body.ttsEngine || undefined,
- ttsVoice: req.body.ttsVoice,
- ttsStyle: req.body.ttsStyle || undefined,
- captionStyle: req.body.captionStyle || undefined,
- showLogo: req.body.showLogo !== "false",
- storeName: await resolveStoreName(user.id, req.body.selectedPageId),
- tempFiles: uploaded.map((f) => f.path),
- });
- if (!parsed.success) {
- await removeFiles(uploaded);
- return res.status(400).json({ error: parsed.error.issues[0]?.message ?? "Paramètres invalides" });
- }
-
- const job = await enqueueReelJob({ kind: "images", userId: user.id, params: parsed.data });
- res.json({ jobId: job.id, message: "Rendu démarré en arrière-plan" });
- } catch (err) {
- console.error("❌ Remotion pre-render error:", err);
- await removeFiles(uploaded);
- res.status(500).json({ error: "Erreur lors du lancement du rendu: " + (err instanceof Error ? err.message : err) });
- }
-});
-
-async function removeFiles(files: Express.Multer.File[]): Promise {
- await Promise.all(files.map((f) => fs.promises.unlink(f.path).catch(() => { /* déjà absent */ })));
-}
-
-/**
- * POST /api/remotion/publish
- * Range la vidéo rendue dans la médiathèque puis la publie (ou la planifie).
- */
-remotionRouter.post("/publish", async (req, res) => {
- try {
- const user = req.user as User;
-
- const { videoUrl, pageIds, scheduledFor, description } = req.body as {
- videoUrl: string;
- pageIds: string[];
- scheduledFor?: string;
- description?: string;
- };
-
- if (!videoUrl) return res.status(400).json({ error: "videoUrl requis" });
- if (!pageIds?.length) return res.status(400).json({ error: "Au moins une page requise" });
-
- // Chemin local du rendu à partir de son URL temporaire
- const filename = path.basename(videoUrl.split("?")[0]);
- const localPath = path.join(uploadDir, filename);
- if (!fs.existsSync(localPath)) {
- return res.status(400).json({ error: "Fichier vidéo introuvable (expiré ?)" });
- }
-
- const videoBuffer = await fs.promises.readFile(localPath);
- const mediaRecord = await storeRenderedVideo(user.id, videoBuffer, filename);
-
- const post = await dbStorage.createPost({
- userId: user.id,
- content: description || "",
- aiGenerated: "false",
- status: scheduledFor ? "scheduled" : "draft",
- scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
- });
- await dbStorage.updatePostMedia(post.id, [mediaRecord.id]);
-
- const results = await publishReelToPages({
- postId: post.id,
- pageIds,
- videoBuffer,
- description: description || "",
- scheduledFor,
- });
-
- res.json({ success: results.some((r) => r.success), results, postId: post.id });
- } catch (err) {
- console.error("❌ Remotion publish error:", err);
- res.status(500).json({ error: "Erreur lors de la publication: " + (err instanceof Error ? err.message : err) });
- }
-});
diff --git a/server/services/reels/imagesPipeline.ts b/server/services/reels/imagesPipeline.ts
deleted file mode 100644
index d58ca7a..0000000
--- a/server/services/reels/imagesPipeline.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-/**
- * Rendu d'un Reel à partir d'images avec Remotion.
- * Exécuté par la file `reel_jobs` ; la publication reste une étape distincte,
- * déclenchée par l'utilisateur après l'aperçu.
- */
-
-import fs from "fs";
-import path from "path";
-import { cleanCaptionText, computeImagesTiming } from "@shared/captions";
-import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
-import { ffmpegService } from "../ffmpeg";
-import { generateVideoThumbnail } from "../thumbnail";
-import type { JobContext } from "./queue";
-import { resolveGeminiApiKey, resolveLogoPath, resolveQwenTarget } from "./assets";
-import { REMOTION_TEMP_DIR, localHttpUrl, tempFileUrl, toDataUrl } from "./remotionAssets";
-import { renderReelComposition } from "./remotionRenderer";
-
-export { REMOTION_TEMP_DIR };
-
-export async function runImagesReelJob({ job, progress }: JobContext): Promise {
- const params = imagesReelParamsSchema.parse(job.params);
- const jobTempFiles: string[] = [];
-
- try {
- await progress(5, "prepare");
- const images = await Promise.all(params.imageUrls.map(toDataUrl));
- const logoPath = await resolveLogoPath();
- const logoUrl = logoPath ? await toDataUrl(logoPath) : undefined;
- const musicUrl = params.musicUrl ? localHttpUrl(params.musicUrl) : undefined;
- const { overlayText, storeName } = params;
-
- // --- Voix et minutage réel des mots ---
- let audioUrl: string | undefined;
- let words: { text: string; start: number; end: number }[] = [];
- let audioDuration = 0;
-
- const spokenText = overlayText ? cleanCaptionText(overlayText) : "";
- if (params.ttsEnabled && spokenText) {
- await progress(15, "voice");
- const voice = await ffmpegService.previewVoice(spokenText, {
- voice: params.ttsVoice,
- engine: params.ttsEngine,
- style: params.ttsStyle,
- geminiApiKey: await resolveGeminiApiKey(params.ttsEngine),
- qwen: await resolveQwenTarget(params.ttsEngine),
- });
- for (const warning of voice.warnings) console.warn(`⚠️ [Reels] ${warning}`);
-
- const audioPath = path.join(REMOTION_TEMP_DIR, `tts-${job.id}.mp3`);
- await fs.promises.writeFile(audioPath, voice.audio);
- jobTempFiles.push(audioPath);
- audioUrl = tempFileUrl(audioPath);
- audioDuration = voice.duration;
- words = voice.words;
- }
-
- // --- Durée : 25 à 30 s (mêmes règles que l'aperçu) ---
- const { total, endingSeconds } = computeImagesTiming({
- imageCount: images.length,
- voiceDuration: audioDuration,
- hasEnding: Boolean(logoUrl || storeName),
- });
-
- await progress(25, "render");
- const outputLocation = path.join(REMOTION_TEMP_DIR, `out-${job.id}.mp4`);
- let lastReported = 25;
- await renderReelComposition({
- compositionId: "ImageVideo",
- outputLocation,
- inputProps: {
- images,
- totalDuration: total,
- words,
- captionStyle: params.captionStyle,
- audioUrl,
- musicUrl,
- musicVolume: params.musicVolume,
- logoUrl,
- showWatermark: params.showLogo,
- storeName,
- endingSeconds,
- },
- onProgress: (ratio) => {
- const pct = 25 + Math.floor(ratio * 70);
- if (pct >= lastReported + 5) {
- lastReported = pct;
- progress(pct, "render").catch(() => { /* non bloquant */ });
- }
- },
- });
-
- const thumbnailPath = outputLocation.replace(/\.mp4$/, "-thumb.jpg");
- const thumbnailOk = await generateVideoThumbnail(outputLocation, thumbnailPath, 2);
-
- return {
- url: `/uploads/temp/${path.basename(outputLocation)}`,
- thumbnailUrl: thumbnailOk ? `/uploads/temp/${path.basename(thumbnailPath)}` : null,
- };
- } finally {
- // Images, musique importée et voix ne servent plus une fois le rendu fini
- for (const file of [...params.tempFiles, ...jobTempFiles]) {
- await fs.promises.unlink(file).catch(() => { /* déjà absent */ });
- }
- }
-}
diff --git a/server/services/reels/index.ts b/server/services/reels/index.ts
index a244559..4de4b98 100644
--- a/server/services/reels/index.ts
+++ b/server/services/reels/index.ts
@@ -5,10 +5,8 @@
import { registerReelJobHandler, startReelWorker } from "./queue";
import { runVideoReelJob } from "./videoPipeline";
-import { runImagesReelJob } from "./imagesPipeline";
export function startReelProcessing(): Promise {
registerReelJobHandler("video", runVideoReelJob);
- registerReelJobHandler("images", runImagesReelJob);
return startReelWorker();
}
diff --git a/server/services/reels/remotionRenderer.ts b/server/services/reels/remotionRenderer.ts
index cc05a73..484d496 100644
--- a/server/services/reels/remotionRenderer.ts
+++ b/server/services/reels/remotionRenderer.ts
@@ -45,7 +45,7 @@ function renderConcurrency(): number {
}
export async function renderReelComposition(options: {
- compositionId: "ReelVideo" | "ImageVideo";
+ compositionId: "ReelVideo";
inputProps: Record;
outputLocation: string;
onProgress?: (ratio: number) => void;
diff --git a/shared/captions.test.ts b/shared/captions.test.ts
index b761768..4a8560b 100644
--- a/shared/captions.test.ts
+++ b/shared/captions.test.ts
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
cleanCaptionText,
- computeImagesTiming,
computeReelTiming,
groupLines,
offsetWords,
@@ -37,14 +36,6 @@ describe("fondu final", () => {
});
});
-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 }));
diff --git a/shared/captions.ts b/shared/captions.ts
index fe475c6..5a63212 100644
--- a/shared/captions.ts
+++ b/shared/captions.ts
@@ -129,19 +129,3 @@ export function spreadWords(text: string, start: number, end: number): TimedWord
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 };
-}
diff --git a/shared/reel.test.ts b/shared/reel.test.ts
index 01b6c4e..93a5bea 100644
--- a/shared/reel.test.ts
+++ b/shared/reel.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { imagesReelParamsSchema, ttsPreviewSchema, videoReelParamsSchema } from "./reel";
+import { ttsPreviewSchema, videoReelParamsSchema } from "./reel";
describe("videoReelParamsSchema", () => {
it("exige une vidéo et une page, avec des messages lisibles", () => {
@@ -22,12 +22,6 @@ describe("videoReelParamsSchema", () => {
});
});
-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);
diff --git a/shared/reel.ts b/shared/reel.ts
index 9e6e34b..1946bc6 100644
--- a/shared/reel.ts
+++ b/shared/reel.ts
@@ -48,24 +48,6 @@ export const videoReelParamsSchema = z.object({
export type VideoReelParams = z.infer;
-/** Reel construit à partir d'images (rendu Remotion). */
-export const imagesReelParamsSchema = z.object({
- // Chemins relatifs /uploads/... ou URL absolues
- imageUrls: z.array(z.string().min(1)).min(1, "Aucune image fournie").max(4),
- overlayText: optionalText,
- musicUrl: optionalText,
- musicVolume: z.number().min(0).max(2).default(0.3),
- ttsEnabled: z.boolean().default(true),
- ttsEngine: z.enum(TTS_ENGINES).optional(),
- ttsVoice: optionalText,
- ttsStyle: z.enum(TTS_STYLES).optional(),
- captionStyle: z.enum(CAPTION_STYLES).default(DEFAULT_CAPTION_STYLE),
- showLogo: z.boolean().default(true),
- storeName: z.string().optional(),
- // Fichiers temporaires à supprimer une fois le rendu terminé
- tempFiles: z.array(z.string()).default([]),
-});
-
/** Aperçu de la voix. */
export const ttsPreviewSchema = z.object({
text: z.string({ required_error: "Texte requis" }).trim().min(1, "Texte requis").max(2000),
@@ -74,14 +56,6 @@ export const ttsPreviewSchema = z.object({
ttsStyle: z.enum(TTS_STYLES).optional(),
});
-export type ImagesReelParams = z.infer;
-
-export type ReelJobKind = "video" | "images";
+export type ReelJobKind = "video";
export type ReelJobStatus = "pending" | "processing" | "completed" | "failed";
-
-/** Résultat d'un rendu d'images, consulté par le client pour l'aperçu. */
-export interface ImagesReelResult {
- url: string;
- thumbnailUrl: string | null;
-}