diff --git a/Dockerfile b/Dockerfile index c4c6579..d7c6ef1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,8 +25,8 @@ RUN npm run build FROM node:20-alpine AS runtime WORKDIR /app -# Tesseract OCR — English language data only (add more langs as needed) -RUN apk add --no-cache tesseract-ocr tesseract-ocr-data-eng +# Tesseract OCR + ffmpeg (for video metadata via ffprobe) +RUN apk add --no-cache tesseract-ocr tesseract-ocr-data-eng ffmpeg # Install production deps only COPY backend/package*.json ./ diff --git a/backend/src/routes/memes.ts b/backend/src/routes/memes.ts index 87e63e8..403a2c4 100644 --- a/backend/src/routes/memes.ts +++ b/backend/src/routes/memes.ts @@ -3,12 +3,16 @@ import type { MultipartFile } from '@fastify/multipart'; import { v4 as uuidv4 } from 'uuid'; import db, { UNSORTED_ID } from '../db.js'; import { buildFilePath, deleteFile, getExtension } from '../services/storage.js'; -import { extractMeta, resizeImage, saveBuffer } from '../services/image.js'; +import { extractMeta, extractVideoMeta, resizeImage, saveBuffer } from '../services/image.js'; import { extractText } from '../services/ocr.js'; import { requireAuth } from '../auth.js'; import type { ListQuery, UpdateBody, RescaleBody, MoveBody, Meme } from '../types.js'; -const ALLOWED_MIMES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); +const ALLOWED_MIMES = new Set([ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', + 'video/mp4', 'video/webm', 'video/quicktime', +]); +const VIDEO_MIMES = new Set(['video/mp4', 'video/webm', 'video/quicktime']); function getMemeTags(memeId: string): string[] { return ( @@ -143,7 +147,10 @@ export async function memesRoutes(app: FastifyInstance) { const filePath = buildFilePath(id, ext); await saveBuffer(buffer, filePath); - const meta = await extractMeta(filePath); + const isVideo = VIDEO_MIMES.has(mimeType); + const meta = isVideo + ? await extractVideoMeta(filePath, mimeType) + : await extractMeta(filePath); const fields = file.fields as Record; const title = fields.title?.value ?? file.filename ?? 'Untitled'; @@ -160,10 +167,12 @@ export async function memesRoutes(app: FastifyInstance) { if (tagsRaw) setMemeTags(id, tagsRaw.split(',')); - // Fire OCR in the background — doesn't block the upload response - extractText(filePath, mimeType).then((text) => { - if (text) db.prepare('UPDATE memes SET ocr_text = ? WHERE id = ?').run(text, id); - }); + // OCR only makes sense for images + if (!isVideo) { + extractText(filePath, mimeType).then((text) => { + if (text) db.prepare('UPDATE memes SET ocr_text = ? WHERE id = ?').run(text, id); + }); + } return reply.status(201).send(getMemeById(id)); }); diff --git a/backend/src/services/image.ts b/backend/src/services/image.ts index b86bd37..56dba19 100644 --- a/backend/src/services/image.ts +++ b/backend/src/services/image.ts @@ -1,7 +1,11 @@ import sharp from 'sharp'; import fs from 'fs'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; import { absolutePath, ensureDir } from './storage.js'; +const execFileAsync = promisify(execFile); + export interface ImageMeta { width: number; height: number; @@ -29,6 +33,30 @@ export async function extractMeta(filePath: string): Promise { }; } +export async function extractVideoMeta(filePath: string, mimeType: string): Promise { + const abs = absolutePath(filePath); + const stat = fs.statSync(abs); + try { + const { stdout } = await execFileAsync('ffprobe', [ + '-v', 'quiet', + '-print_format', 'json', + '-show_streams', + abs, + ]); + const data = JSON.parse(stdout); + const video = (data.streams as any[])?.find((s) => s.codec_type === 'video'); + return { + width: video?.width ?? 1280, + height: video?.height ?? 720, + mimeType, + size: stat.size, + }; + } catch { + // ffprobe unavailable or failed — use safe defaults + return { width: 1280, height: 720, mimeType, size: stat.size }; + } +} + export async function saveBuffer(buffer: Buffer, destRelPath: string): Promise { ensureDir(destRelPath); const abs = absolutePath(destRelPath); diff --git a/backend/src/services/storage.ts b/backend/src/services/storage.ts index 5477d16..f8aac08 100644 --- a/backend/src/services/storage.ts +++ b/backend/src/services/storage.ts @@ -44,6 +44,9 @@ export function getExtension(mimeType: string): string { 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'video/quicktime': 'mov', }; return map[mimeType] ?? 'jpg'; } diff --git a/frontend/src/components/MemeCard.tsx b/frontend/src/components/MemeCard.tsx index 5bdd218..5aaaba3 100644 --- a/frontend/src/components/MemeCard.tsx +++ b/frontend/src/components/MemeCard.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react'; -import { Share2, Eye, Layers } from 'lucide-react'; +import { useState, useRef, useEffect } from 'react'; +import { Share2, Eye, Layers, Play } from 'lucide-react'; import type { Meme } from '../api/client'; import { api } from '../api/client'; @@ -9,9 +9,38 @@ interface Props { onShare: (meme: Meme) => void; } +function isVideo(mimeType: string) { + return mimeType.startsWith('video/'); +} + export function MemeCard({ meme, onOpen, onShare }: Props) { const [loaded, setLoaded] = useState(false); const [hovered, setHovered] = useState(false); + const videoRef = useRef(null); + + // Autoplay video when it enters the viewport; pause when it leaves + useEffect(() => { + if (!isVideo(meme.mime_type) || !videoRef.current) return; + + const el = videoRef.current; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + el.play().catch(() => {}); + } else { + el.pause(); + } + }, + { threshold: 0.25 } + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [meme.mime_type]); + + const aspectPad = meme.width && meme.height + ? `${(meme.height / meme.width) * 100}%` + : '56.25%'; // 16:9 fallback return (
setHovered(false)} onClick={() => onOpen(meme)} > - {/* Image */} - {meme.title} setLoaded(true)} - className={`w-full block transition-all duration-500 ${ - loaded ? 'opacity-100' : 'opacity-0' - } ${hovered ? 'scale-[1.02]' : 'scale-100'} transition-transform duration-300`} - /> - {/* Skeleton while loading */} {!loaded && (
)} + {isVideo(meme.mime_type) ? ( +
- {/* Child indicator */} - {/* (shown from parent detail) — not needed on card itself */} - - {/* Dimensions badge */} + {/* Dimensions / resolution badge */}
{meme.width}×{meme.height} diff --git a/frontend/src/components/MemeDetail.tsx b/frontend/src/components/MemeDetail.tsx index 706388e..7f333de 100644 --- a/frontend/src/components/MemeDetail.tsx +++ b/frontend/src/components/MemeDetail.tsx @@ -119,7 +119,7 @@ export function MemeDetail({ memeId, onClose }: Props) { ) )} - {isAdmin && !meme.parent_id && ( + {isAdmin && !meme.parent_id && !meme.mime_type.startsWith('video/') && (