feat(reels): montage Remotion unifié, aperçu en direct, tests et CI

Montage (étape 3) :
- Nouvelle composition ReelVideo : sous-titres animés mot à mot (3 styles :
  Impact, Surligné, Épuré), logo, effet de fin et fondu, en React
- Le service Python prépare l'image (recadrage, HDR, stabilisation, dernière
  image figée) et la piste son finale (/prepare-reel) ; Remotion compose
- Reels d'images sur les mêmes composants, avec le vrai minutage de la voix
- Police Montserrat embarquée (plus de dépendance à Google Fonts)
- REEL_RENDERER=ffmpeg conserve le rendu FFmpeg, plus rapide, en secours
- Script de pré-bundle réparé (échouait en silence : require en ESM)

Interface (étape 4) :
- Aperçu en direct avec @remotion/player, identique au rendu final ; la voix
  testée cale les sous-titres, sinon minutage estimé
- Choix du style de sous-titres sur les 4 pages Reel
- Vraie progression : étape réelle du rendu, échecs visibles 24 h avec leur
  cause ; fin de la barre simulée et du faux « publié avec succès »

Outillage (étape 5) :
- Tests vitest (minutage identique à Python, validation, sécurité) et
  pytest ; CI GitHub Actions (tsc, tests, build, ruff)
- Captures d'écran, out.mp4 et scripts de test retirés de la racine

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
This commit is contained in:
Claude committed 2026-09-24 14:37:42 +00:00
1 parent 3c6a66bf64
commit 9b66a6dc5a
64 files changed
+2488 -782

No files matched your search

+63
View File
@@ -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 }]);
});
});
+137
View File
@@ -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 };
}
+35
View File
@@ -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);
});
});
+3
View File
@@ -5,6 +5,7 @@
*/
import { z } from "zod";
import { CAPTION_STYLES, DEFAULT_CAPTION_STYLE } from "./captions";
import { TTS_ENGINES, TTS_STYLES } from "./voices";
const optionalText = z
@@ -33,6 +34,7 @@ export const videoReelParamsSchema = z.object({
fontSize: z.number().int().min(16).max(200).default(64),
musicVolume: z.number().min(0).max(2).default(0.25),
drawText: z.boolean().default(true),
captionStyle: z.enum(CAPTION_STYLES).default(DEFAULT_CAPTION_STYLE),
stabilize: z.boolean().default(false),
enableEndingEffect: z.boolean().default(true),
// 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(),
ttsVoice: optionalText,
ttsStyle: z.enum(TTS_STYLES).optional(),
captionStyle: z.enum(CAPTION_STYLES).default(DEFAULT_CAPTION_STYLE),
storeName: z.string().optional(),
// Fichiers temporaires à supprimer une fois le rendu terminé
tempFiles: z.array(z.string()).default([]),