mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-22 14:38:11 +02:00
feat(reels): montage Remotion unifié, aperçu en direct, tests et CI
Montage (étape 3) : - Nouvelle composition ReelVideo : sous-titres animés mot à mot (3 styles : Impact, Surligné, Épuré), logo, effet de fin et fondu, en React - Le service Python prépare l'image (recadrage, HDR, stabilisation, dernière image figée) et la piste son finale (/prepare-reel) ; Remotion compose - Reels d'images sur les mêmes composants, avec le vrai minutage de la voix - Police Montserrat embarquée (plus de dépendance à Google Fonts) - REEL_RENDERER=ffmpeg conserve le rendu FFmpeg, plus rapide, en secours - Script de pré-bundle réparé (échouait en silence : require en ESM) Interface (étape 4) : - Aperçu en direct avec @remotion/player, identique au rendu final ; la voix testée cale les sous-titres, sinon minutage estimé - Choix du style de sous-titres sur les 4 pages Reel - Vraie progression : étape réelle du rendu, échecs visibles 24 h avec leur cause ; fin de la barre simulée et du faux « publié avec succès » Outillage (étape 5) : - Tests vitest (minutage identique à Python, validation, sécurité) et pytest ; CI GitHub Actions (tsc, tests, build, ruff) - Captures d'écran, out.mp4 et scripts de test retirés de la racine Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
This commit is contained in:
64 files changed
+2488
-782
No files matched your search
@@ -0,0 +1,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 { 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([]),
|
||||
|
||||
Reference in new issue
Block a user