16 Commits

Author SHA1 Message Date
Mathis HERRIOT
fa673d0f80 chore: bump version to 1.5.6
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m45s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m32s
2026-01-28 15:39:56 +01:00
Mathis HERRIOT
8df6d15b19 refactor: update content card and list layout for better responsiveness
- Removed unused aspect-video class from content card layout.
- Improved content list layout by switching to a responsive grid system.
2026-01-28 15:39:44 +01:00
Mathis HERRIOT
0144421f03 chore: bump version to 1.5.5
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m36s
2026-01-28 15:00:02 +01:00
Mathis HERRIOT
df9a6c6f36 refactor: update test mocks to use addOutputOptions for consistency
- Replaced `outputOptions` with `addOutputOptions` in media service spec test to align with updated FFmpeg strategy.
2026-01-28 14:59:40 +01:00
Mathis HERRIOT
15426a9e18 chore: bump version to 1.5.4
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m10s
CI/CD Pipeline / Valider frontend (push) Successful in 1m41s
CI/CD Pipeline / Valider documentation (push) Successful in 1m45s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-28 14:45:48 +01:00
Mathis HERRIOT
a28844e9b7 refactor: replace outputOptions with addOutputOptions in video processor strategy
- Updated FFmpeg command to use `addOutputOptions` for improved readability and consistency.
2026-01-28 14:45:41 +01:00
Mathis HERRIOT
ae916931f6 chore: bump version to 1.5.3
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m33s
2026-01-28 14:39:43 +01:00
Mathis HERRIOT
e4dc5dd10b chore: simplify FFmpeg installation in Dockerfile
- Replaced custom FFmpeg build process with `apk add --no-cache ffmpeg` for reduced complexity and faster builds.
2026-01-28 14:39:33 +01:00
Mathis HERRIOT
878c35cbcd chore: bump version to 1.5.2
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m35s
CI/CD Pipeline / Valider documentation (push) Successful in 1m42s
CI/CD Pipeline / Valider frontend (push) Successful in 1m49s
CI/CD Pipeline / Déploiement en Production (push) Failing after 18s
2026-01-28 14:32:57 +01:00
Mathis HERRIOT
8cf0036248 chore: update Dockerfile to fix FFmpeg download URL
- Replaced the FFmpeg URL with a version that supports redirection handling using `-L` flag.
2026-01-28 14:32:52 +01:00
Mathis HERRIOT
c389024f59 chore: bump version to 1.5.1
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Valider frontend (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Failing after 13s
2026-01-28 14:27:43 +01:00
Mathis HERRIOT
bbdbe58af5 feat: add FFmpeg installation to Dockerfile for media processing
- Configured Dockerfile to install FFmpeg with support for various codecs and libraries.
- Optimized build process by cleaning up temporary files post-installation.
2026-01-28 14:27:29 +01:00
Mathis HERRIOT
5951e41eb5 chore: bump version to 1.5.0
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m44s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m35s
2026-01-28 14:14:42 +01:00
Mathis HERRIOT
7442236e8d feat: add 'video' as a new value to content_type enum
- Updated backend migrations to include 'video' in the `content_type` enum.
- Synced migration metadata files to reflect the schema changes.
2026-01-28 14:14:05 +01:00
Mathis HERRIOT
3ef7292287 feat: add captions support for video uploads
- Enhanced video previews with `<track>` element for captions.
2026-01-28 14:10:59 +01:00
Mathis HERRIOT
f1a571196d 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.
2026-01-28 14:07:00 +01:00
18 changed files with 1728 additions and 34 deletions

View File

@@ -0,0 +1 @@
ALTER TYPE "public"."content_type" ADD VALUE 'video';

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,13 @@
"when": 1768423315172,
"tag": "0006_friendly_adam_warlock",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1769605995410,
"tag": "0007_melodic_synch",
"breakpoints": true
}
]
}

View File

@@ -4,6 +4,8 @@ ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN apk add --no-cache ffmpeg
FROM base AS build
WORKDIR /usr/src/app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./

View File

@@ -1,6 +1,6 @@
{
"name": "@memegoat/backend",
"version": "1.4.1",
"version": "1.5.6",
"description": "",
"author": "",
"private": true,

View File

@@ -48,6 +48,7 @@ export const envSchema = z.object({
// Media Limits
MAX_IMAGE_SIZE_KB: z.coerce.number().default(512),
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>;

View File

@@ -55,22 +55,31 @@ export class ContentsService {
"image/webp",
"image/gif",
"video/webm",
"video/mp4",
"video/quicktime",
];
if (!allowedMimeTypes.includes(file.mimetype)) {
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 maxSizeKb = isGif
? this.configService.get<number>("MAX_GIF_SIZE_KB", 1024)
: this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
const isVideo = file.mimetype.startsWith("video/");
let maxSizeKb: number;
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) {
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
let processed: MediaProcessingResult;
if (file.mimetype.startsWith("image/")) {
// Image ou GIF -> WebP (format moderne, bien supporté)
if (file.mimetype.startsWith("image/") && file.mimetype !== "image/gif") {
// Image -> WebP (format moderne, bien supporté)
processed = await this.mediaService.processImage(file.buffer, "webp");
} else if (file.mimetype.startsWith("video/")) {
// Vidéo -> WebM
} else if (
file.mimetype.startsWith("video/") ||
file.mimetype === "image/gif"
) {
// Vidéo ou GIF -> WebM
processed = await this.mediaService.processVideo(file.buffer, "webm");
} else {
throw new BadRequestException("Format de fichier non supporté");

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ import { categories } from "./categories";
import { tags } from "./tags";
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(
"contents",

View File

@@ -75,7 +75,7 @@ describe("MediaService", () => {
toFormat: jest.fn().mockReturnThis(),
videoCodec: jest.fn().mockReturnThis(),
audioCodec: jest.fn().mockReturnThis(),
outputOptions: jest.fn().mockReturnThis(),
addOutputOptions: jest.fn().mockReturnThis(),
on: jest.fn().mockImplementation(function (event, cb) {
if (event === "end") setTimeout(cb, 0);
return this;

View File

@@ -12,7 +12,7 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
private readonly logger = new Logger(VideoProcessorStrategy.name);
canHandle(mimeType: string): boolean {
return mimeType.startsWith("video/");
return mimeType.startsWith("video/") || mimeType === "image/gif";
}
async process(
@@ -37,13 +37,13 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
.toFormat("webm")
.videoCodec("libvpx-vp9")
.audioCodec("libopus")
.outputOptions("-crf 30", "-b:v 0");
.addOutputOptions("-crf", "30", "-b:v", "0");
} else {
command = command
.toFormat("mp4")
.videoCodec("libaom-av1")
.audioCodec("libopus")
.outputOptions("-crf 34", "-b:v 0", "-strict experimental");
.addOutputOptions("-crf", "34", "-b:v", "0", "-strict", "experimental");
}
command

View File

@@ -1,6 +1,6 @@
{
"name": "@memegoat/frontend",
"version": "1.4.1",
"version": "1.5.6",
"private": true,
"scripts": {
"dev": "next dev",

View File

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

View File

@@ -158,7 +158,7 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="p-0 relative bg-zinc-200 dark:bg-zinc-900 aspect-square sm:aspect-video md:aspect-square flex items-center justify-center">
<CardContent className="p-0 relative bg-zinc-200 dark:bg-zinc-900 aspect-square flex items-center justify-center">
<Link href={`/meme/${content.slug}`} className="w-full h-full relative">
{content.mimeType.startsWith("image/") ? (
<Image

View File

@@ -68,9 +68,9 @@ export function ContentList({ fetchFn, title }: ContentListProps) {
});
return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
<div className="max-w-7xl mx-auto py-8 px-4 space-y-8">
{title && <h1 className="text-2xl font-bold">{title}</h1>}
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{contents.map((content) => (
<ContentCard key={content.id} content={content} onUpdate={fetchInitial} />
))}

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@memegoat/source",
"version": "1.4.1",
"version": "1.5.6",
"description": "",
"scripts": {
"version:get": "cmake -P version.cmake GET",