feat: add video upload feature with support for validation and processing

- Introduced "video" as a new content type across backend and frontend.
- Updated validation schemas and MIME-type handling for video files.
- Implemented file size limits for videos (10 MB max) in configuration.
- Enhanced upload flow with auto-detection of file types (image, GIF, video).
- Expanded media processing to handle video files and convert them to WebM format.
This commit is contained in:
Mathis HERRIOT
2026-01-28 14:07:00 +01:00
parent f4cd20a010
commit f1a571196d
8 changed files with 54 additions and 25 deletions

View File

@@ -48,6 +48,7 @@ export const envSchema = z.object({
// Media Limits // Media Limits
MAX_IMAGE_SIZE_KB: z.coerce.number().default(512), MAX_IMAGE_SIZE_KB: z.coerce.number().default(512),
MAX_GIF_SIZE_KB: z.coerce.number().default(1024), MAX_GIF_SIZE_KB: z.coerce.number().default(1024),
MAX_VIDEO_SIZE_KB: z.coerce.number().default(10240),
}); });
export type Env = z.infer<typeof envSchema>; export type Env = z.infer<typeof envSchema>;

View File

@@ -55,22 +55,31 @@ export class ContentsService {
"image/webp", "image/webp",
"image/gif", "image/gif",
"video/webm", "video/webm",
"video/mp4",
"video/quicktime",
]; ];
if (!allowedMimeTypes.includes(file.mimetype)) { if (!allowedMimeTypes.includes(file.mimetype)) {
throw new BadRequestException( throw new BadRequestException(
"Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, gif.", "Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, mp4, mov, gif.",
); );
} }
const isGif = file.mimetype === "image/gif"; const isGif = file.mimetype === "image/gif";
const maxSizeKb = isGif const isVideo = file.mimetype.startsWith("video/");
? this.configService.get<number>("MAX_GIF_SIZE_KB", 1024) let maxSizeKb: number;
: this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
if (isGif) {
maxSizeKb = this.configService.get<number>("MAX_GIF_SIZE_KB", 1024);
} else if (isVideo) {
maxSizeKb = this.configService.get<number>("MAX_VIDEO_SIZE_KB", 10240);
} else {
maxSizeKb = this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
}
if (file.size > maxSizeKb * 1024) { if (file.size > maxSizeKb * 1024) {
throw new BadRequestException( throw new BadRequestException(
`Fichier trop volumineux. Limite pour ${isGif ? "GIF" : "image"}: ${maxSizeKb} Ko.`, `Fichier trop volumineux. Limite pour ${isGif ? "GIF" : isVideo ? "vidéo" : "image"}: ${maxSizeKb} Ko.`,
); );
} }
@@ -87,11 +96,14 @@ export class ContentsService {
// 2. Transcodage // 2. Transcodage
let processed: MediaProcessingResult; let processed: MediaProcessingResult;
if (file.mimetype.startsWith("image/")) { if (file.mimetype.startsWith("image/") && file.mimetype !== "image/gif") {
// Image ou GIF -> WebP (format moderne, bien supporté) // Image -> WebP (format moderne, bien supporté)
processed = await this.mediaService.processImage(file.buffer, "webp"); processed = await this.mediaService.processImage(file.buffer, "webp");
} else if (file.mimetype.startsWith("video/")) { } else if (
// Vidéo -> WebM file.mimetype.startsWith("video/") ||
file.mimetype === "image/gif"
) {
// Vidéo ou GIF -> WebM
processed = await this.mediaService.processVideo(file.buffer, "webm"); processed = await this.mediaService.processVideo(file.buffer, "webm");
} else { } else {
throw new BadRequestException("Format de fichier non supporté"); throw new BadRequestException("Format de fichier non supporté");

View File

@@ -12,11 +12,12 @@ import {
export enum ContentType { export enum ContentType {
MEME = "meme", MEME = "meme",
GIF = "gif", GIF = "gif",
VIDEO = "video",
} }
export class CreateContentDto { export class CreateContentDto {
@IsEnum(ContentType) @IsEnum(ContentType)
type!: "meme" | "gif"; type!: "meme" | "gif" | "video";
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()

View File

@@ -11,7 +11,7 @@ import { ContentType } from "./create-content.dto";
export class UploadContentDto { export class UploadContentDto {
@IsEnum(ContentType) @IsEnum(ContentType)
type!: "meme" | "gif"; type!: "meme" | "gif" | "video";
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()

View File

@@ -12,7 +12,7 @@ import { categories } from "./categories";
import { tags } from "./tags"; import { tags } from "./tags";
import { users } from "./users"; import { users } from "./users";
export const contentType = pgEnum("content_type", ["meme", "gif"]); export const contentType = pgEnum("content_type", ["meme", "gif", "video"]);
export const contents = pgTable( export const contents = pgTable(
"contents", "contents",

View File

@@ -12,7 +12,7 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
private readonly logger = new Logger(VideoProcessorStrategy.name); private readonly logger = new Logger(VideoProcessorStrategy.name);
canHandle(mimeType: string): boolean { canHandle(mimeType: string): boolean {
return mimeType.startsWith("video/"); return mimeType.startsWith("video/") || mimeType === "image/gif";
} }
async process( async process(

View File

@@ -42,7 +42,7 @@ import type { Category } from "@/types/content";
const uploadSchema = z.object({ const uploadSchema = z.object({
title: z.string().min(3, "Le titre doit faire au moins 3 caractères"), title: z.string().min(3, "Le titre doit faire au moins 3 caractères"),
type: z.enum(["meme", "gif"]), type: z.enum(["meme", "gif", "video"]),
categoryId: z.string().optional(), categoryId: z.string().optional(),
tags: z.string().optional(), tags: z.string().optional(),
}); });
@@ -112,6 +112,16 @@ export default function UploadPage() {
return; return;
} }
setFile(selectedFile); setFile(selectedFile);
// Auto-détection du type
if (selectedFile.type === "image/gif") {
form.setValue("type", "gif");
} else if (selectedFile.type.startsWith("video/")) {
form.setValue("type", "video");
} else {
form.setValue("type", "meme");
}
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
setPreview(reader.result as string); setPreview(reader.result as string);
@@ -182,7 +192,7 @@ export default function UploadPage() {
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<FormLabel>Fichier (Image ou GIF)</FormLabel> <FormLabel>Fichier (Image, GIF ou Vidéo)</FormLabel>
{!preview ? ( {!preview ? (
<button <button
type="button" type="button"
@@ -194,25 +204,29 @@ export default function UploadPage() {
</div> </div>
<p className="font-medium">Cliquez pour choisir un fichier</p> <p className="font-medium">Cliquez pour choisir un fichier</p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
PNG, JPG ou GIF jusqu'à 10Mo PNG, JPG, GIF, MP4 ou MOV jusqu'à 10Mo
</p> </p>
<input <input
id="file-upload" id="file-upload"
type="file" type="file"
className="hidden" className="hidden"
accept="image/*,.gif" accept="image/*,video/mp4,video/webm,video/quicktime,.gif"
onChange={handleFileChange} onChange={handleFileChange}
/> />
</button> </button>
) : ( ) : (
<div className="relative rounded-lg overflow-hidden border bg-zinc-100 dark:bg-zinc-800"> <div className="relative rounded-lg overflow-hidden border bg-zinc-100 dark:bg-zinc-800">
<div className="relative h-[400px] w-full"> <div className="relative h-[400px] w-full flex items-center justify-center">
<NextImage {file?.type.startsWith("video/") ? (
src={preview} <video src={preview} controls className="max-h-full max-w-full" />
alt="Preview" ) : (
fill <NextImage
className="object-contain" src={preview}
/> alt="Preview"
fill
className="object-contain"
/>
)}
</div> </div>
<Button <Button
type="button" type="button"
@@ -260,6 +274,7 @@ export default function UploadPage() {
<SelectContent> <SelectContent>
<SelectItem value="meme">Image fixe</SelectItem> <SelectItem value="meme">Image fixe</SelectItem>
<SelectItem value="gif">GIF Animé</SelectItem> <SelectItem value="gif">GIF Animé</SelectItem>
<SelectItem value="video">Vidéo</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />

View File

@@ -7,7 +7,7 @@ export interface Content {
description?: string; description?: string;
url: string; url: string;
thumbnailUrl?: string; thumbnailUrl?: string;
type: "meme" | "gif"; type: "meme" | "gif" | "video";
mimeType: string; mimeType: string;
size: number; size: number;
width?: number; width?: number;