build video support
This commit is contained in:
@@ -25,8 +25,8 @@ RUN npm run build
|
|||||||
FROM node:20-alpine AS runtime
|
FROM node:20-alpine AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Tesseract OCR — English language data only (add more langs as needed)
|
# Tesseract OCR + ffmpeg (for video metadata via ffprobe)
|
||||||
RUN apk add --no-cache tesseract-ocr tesseract-ocr-data-eng
|
RUN apk add --no-cache tesseract-ocr tesseract-ocr-data-eng ffmpeg
|
||||||
|
|
||||||
# Install production deps only
|
# Install production deps only
|
||||||
COPY backend/package*.json ./
|
COPY backend/package*.json ./
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import type { MultipartFile } from '@fastify/multipart';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import db, { UNSORTED_ID } from '../db.js';
|
import db, { UNSORTED_ID } from '../db.js';
|
||||||
import { buildFilePath, deleteFile, getExtension } from '../services/storage.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 { extractText } from '../services/ocr.js';
|
||||||
import { requireAuth } from '../auth.js';
|
import { requireAuth } from '../auth.js';
|
||||||
import type { ListQuery, UpdateBody, RescaleBody, MoveBody, Meme } from '../types.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[] {
|
function getMemeTags(memeId: string): string[] {
|
||||||
return (
|
return (
|
||||||
@@ -143,7 +147,10 @@ export async function memesRoutes(app: FastifyInstance) {
|
|||||||
const filePath = buildFilePath(id, ext);
|
const filePath = buildFilePath(id, ext);
|
||||||
|
|
||||||
await saveBuffer(buffer, filePath);
|
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<string, { value: string }>;
|
const fields = file.fields as Record<string, { value: string }>;
|
||||||
const title = fields.title?.value ?? file.filename ?? 'Untitled';
|
const title = fields.title?.value ?? file.filename ?? 'Untitled';
|
||||||
@@ -160,10 +167,12 @@ export async function memesRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
if (tagsRaw) setMemeTags(id, tagsRaw.split(','));
|
if (tagsRaw) setMemeTags(id, tagsRaw.split(','));
|
||||||
|
|
||||||
// Fire OCR in the background — doesn't block the upload response
|
// OCR only makes sense for images
|
||||||
|
if (!isVideo) {
|
||||||
extractText(filePath, mimeType).then((text) => {
|
extractText(filePath, mimeType).then((text) => {
|
||||||
if (text) db.prepare('UPDATE memes SET ocr_text = ? WHERE id = ?').run(text, id);
|
if (text) db.prepare('UPDATE memes SET ocr_text = ? WHERE id = ?').run(text, id);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return reply.status(201).send(getMemeById(id));
|
return reply.status(201).send(getMemeById(id));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
import { absolutePath, ensureDir } from './storage.js';
|
import { absolutePath, ensureDir } from './storage.js';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
export interface ImageMeta {
|
export interface ImageMeta {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -29,6 +33,30 @@ export async function extractMeta(filePath: string): Promise<ImageMeta> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function extractVideoMeta(filePath: string, mimeType: string): Promise<ImageMeta> {
|
||||||
|
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<void> {
|
export async function saveBuffer(buffer: Buffer, destRelPath: string): Promise<void> {
|
||||||
ensureDir(destRelPath);
|
ensureDir(destRelPath);
|
||||||
const abs = absolutePath(destRelPath);
|
const abs = absolutePath(destRelPath);
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export function getExtension(mimeType: string): string {
|
|||||||
'image/png': 'png',
|
'image/png': 'png',
|
||||||
'image/gif': 'gif',
|
'image/gif': 'gif',
|
||||||
'image/webp': 'webp',
|
'image/webp': 'webp',
|
||||||
|
'video/mp4': 'mp4',
|
||||||
|
'video/webm': 'webm',
|
||||||
|
'video/quicktime': 'mov',
|
||||||
};
|
};
|
||||||
return map[mimeType] ?? 'jpg';
|
return map[mimeType] ?? 'jpg';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Share2, Eye, Layers } from 'lucide-react';
|
import { Share2, Eye, Layers, Play } from 'lucide-react';
|
||||||
import type { Meme } from '../api/client';
|
import type { Meme } from '../api/client';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
|
|
||||||
@@ -9,9 +9,38 @@ interface Props {
|
|||||||
onShare: (meme: Meme) => void;
|
onShare: (meme: Meme) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVideo(mimeType: string) {
|
||||||
|
return mimeType.startsWith('video/');
|
||||||
|
}
|
||||||
|
|
||||||
export function MemeCard({ meme, onOpen, onShare }: Props) {
|
export function MemeCard({ meme, onOpen, onShare }: Props) {
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -20,23 +49,44 @@ export function MemeCard({ meme, onOpen, onShare }: Props) {
|
|||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
onClick={() => onOpen(meme)}
|
onClick={() => onOpen(meme)}
|
||||||
>
|
>
|
||||||
{/* Image */}
|
{/* Skeleton while loading */}
|
||||||
|
{!loaded && (
|
||||||
|
<div
|
||||||
|
className="w-full bg-zinc-800 animate-pulse"
|
||||||
|
style={{ paddingTop: aspectPad }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isVideo(meme.mime_type) ? (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={api.imageUrl(meme.file_path)}
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
onLoadedMetadata={() => setLoaded(true)}
|
||||||
|
className={`w-full block transition-all duration-500 ${
|
||||||
|
loaded ? 'opacity-100' : 'opacity-0 absolute inset-0'
|
||||||
|
} ${hovered ? 'scale-[1.02]' : 'scale-100'} transition-transform duration-300`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<img
|
<img
|
||||||
src={api.imageUrl(meme.file_path)}
|
src={api.imageUrl(meme.file_path)}
|
||||||
alt={meme.title}
|
alt={meme.title}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onLoad={() => setLoaded(true)}
|
onLoad={() => setLoaded(true)}
|
||||||
className={`w-full block transition-all duration-500 ${
|
className={`w-full block transition-all duration-500 ${
|
||||||
loaded ? 'opacity-100' : 'opacity-0'
|
loaded ? 'opacity-100' : 'opacity-0 absolute inset-0'
|
||||||
} ${hovered ? 'scale-[1.02]' : 'scale-100'} transition-transform duration-300`}
|
} ${hovered ? 'scale-[1.02]' : 'scale-100'} transition-transform duration-300`}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Skeleton while loading */}
|
{/* Video badge */}
|
||||||
{!loaded && (
|
{isVideo(meme.mime_type) && loaded && !hovered && (
|
||||||
<div
|
<div className="absolute top-2 left-2 flex items-center gap-1 px-1.5 py-0.5 rounded bg-zinc-900/80 text-zinc-400">
|
||||||
className="absolute inset-0 bg-zinc-800 animate-pulse"
|
<Play size={10} className="fill-zinc-400" />
|
||||||
style={{ paddingTop: `${(meme.height / meme.width) * 100}%` }}
|
<span className="text-xs">video</span>
|
||||||
/>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hover overlay */}
|
{/* Hover overlay */}
|
||||||
@@ -67,20 +117,14 @@ export function MemeCard({ meme, onOpen, onShare }: Props) {
|
|||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => { e.stopPropagation(); onOpen(meme); }}
|
||||||
e.stopPropagation();
|
|
||||||
onOpen(meme);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
|
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||||
>
|
>
|
||||||
<Eye size={12} />
|
<Eye size={12} />
|
||||||
View
|
View
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => { e.stopPropagation(); onShare(meme); }}
|
||||||
e.stopPropagation();
|
|
||||||
onShare(meme);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg bg-accent/30 hover:bg-accent/50 text-purple-200 transition-colors"
|
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg bg-accent/30 hover:bg-accent/50 text-purple-200 transition-colors"
|
||||||
>
|
>
|
||||||
<Share2 size={12} />
|
<Share2 size={12} />
|
||||||
@@ -90,10 +134,7 @@ export function MemeCard({ meme, onOpen, onShare }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Child indicator */}
|
{/* Dimensions / resolution badge */}
|
||||||
{/* (shown from parent detail) — not needed on card itself */}
|
|
||||||
|
|
||||||
{/* Dimensions badge */}
|
|
||||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-zinc-900/80 text-zinc-400">
|
<span className="text-xs px-1.5 py-0.5 rounded bg-zinc-900/80 text-zinc-400">
|
||||||
{meme.width}×{meme.height}
|
{meme.width}×{meme.height}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function MemeDetail({ memeId, onClose }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{isAdmin && !meme.parent_id && (
|
{isAdmin && !meme.parent_id && !meme.mime_type.startsWith('video/') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRescale(true)}
|
onClick={() => setShowRescale(true)}
|
||||||
className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors"
|
className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors"
|
||||||
@@ -147,15 +147,28 @@ export function MemeDetail({ memeId, onClose }: Props) {
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex flex-col md:flex-row flex-1 overflow-hidden">
|
<div className="flex flex-col md:flex-row flex-1 overflow-hidden">
|
||||||
{/* Image panel */}
|
{/* Image / video panel */}
|
||||||
<div className="flex-1 flex items-center justify-center bg-zinc-950 p-4 overflow-hidden">
|
<div className="flex-1 flex items-center justify-center bg-zinc-950 p-4 overflow-hidden">
|
||||||
{displayMeme && (
|
{displayMeme && (
|
||||||
|
displayMeme.mime_type.startsWith('video/') ? (
|
||||||
|
<video
|
||||||
|
key={displayMeme.id}
|
||||||
|
src={api.imageUrl(displayMeme.file_path)}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
className="max-w-full max-h-full rounded-lg animate-fade-in"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<img
|
<img
|
||||||
key={displayMeme.id}
|
key={displayMeme.id}
|
||||||
src={api.imageUrl(displayMeme.file_path)}
|
src={api.imageUrl(displayMeme.file_path)}
|
||||||
alt={displayMeme.title}
|
alt={displayMeme.title}
|
||||||
className="max-w-full max-h-full object-contain rounded-lg animate-fade-in"
|
className="max-w-full max-h-full object-contain rounded-lg animate-fade-in"
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface Props {
|
|||||||
defaultCollectionId?: number;
|
defaultCollectionId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
const ALLOWED = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4', 'video/webm', 'video/quicktime'];
|
||||||
|
|
||||||
export function UploadModal({ onClose, defaultCollectionId }: Props) {
|
export function UploadModal({ onClose, defaultCollectionId }: Props) {
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
@@ -88,11 +88,11 @@ export function UploadModal({ onClose, defaultCollectionId }: Props) {
|
|||||||
Drag & drop images here, or{' '}
|
Drag & drop images here, or{' '}
|
||||||
<span className="text-accent">browse files</span>
|
<span className="text-accent">browse files</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-zinc-600 mt-1">JPG, PNG, GIF, WebP — max 100 MB each</p>
|
<p className="text-xs text-zinc-600 mt-1">JPG, PNG, GIF, WebP, MP4, WebM — max 100 MB each</p>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,video/quicktime"
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => e.target.files && addFiles(e.target.files)}
|
onChange={(e) => e.target.files && addFiles(e.target.files)}
|
||||||
|
|||||||
Reference in New Issue
Block a user