feat(mana-media): add unified media processing platform MVP

- Create mana-media service for centralized media handling
- Add upload, processing, and delivery modules
- Configure BullMQ for async transcoding jobs
- Add S3-compatible storage integration
- Create TypeScript client package

Features:
- Multi-format image/video upload
- Async transcoding via ffmpeg
- Adaptive streaming (HLS) support
- Signed URL delivery

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-01 03:25:53 +01:00
parent 5c8120f437
commit cd28a83007
28 changed files with 5318 additions and 0 deletions

View file

@ -0,0 +1,27 @@
export const PROCESS_QUEUE = 'media-process';
export const IMAGE_VARIANTS = {
thumbnail: { width: 200, height: 200, fit: 'cover' as const },
medium: { width: 800, height: 800, fit: 'inside' as const },
large: { width: 1920, height: 1920, fit: 'inside' as const },
};
export const SUPPORTED_IMAGE_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'image/avif',
'image/heic',
'image/heif',
];
export const SUPPORTED_VIDEO_TYPES = ['video/mp4', 'video/quicktime', 'video/webm', 'video/mpeg'];
export const SUPPORTED_AUDIO_TYPES = ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/webm'];
export const SUPPORTED_DOCUMENT_TYPES = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];

View file

@ -0,0 +1,18 @@
import { Module, forwardRef } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { ProcessService } from './process.service';
import { ProcessWorker } from './process.worker';
import { PROCESS_QUEUE } from './process.constants';
import { UploadModule } from '../upload/upload.module';
@Module({
imports: [
BullModule.registerQueue({
name: PROCESS_QUEUE,
}),
forwardRef(() => UploadModule),
],
providers: [ProcessService, ProcessWorker],
exports: [ProcessService],
})
export class ProcessModule {}

View file

@ -0,0 +1,150 @@
import { Injectable } from '@nestjs/common';
import sharp from 'sharp';
import { StorageService } from '../storage/storage.service';
import { IMAGE_VARIANTS, SUPPORTED_IMAGE_TYPES } from './process.constants';
export interface ProcessResult {
thumbnail?: string;
medium?: string;
large?: string;
metadata?: {
width?: number;
height?: number;
format?: string;
hasAlpha?: boolean;
};
}
@Injectable()
export class ProcessService {
constructor(private storage: StorageService) {}
async processImage(
mediaId: string,
originalKey: string,
mimeType: string
): Promise<ProcessResult> {
if (!SUPPORTED_IMAGE_TYPES.includes(mimeType)) {
return {};
}
// Download original
const originalBuffer = await this.storage.download(originalKey);
// Get metadata
const image = sharp(originalBuffer);
const metadata = await image.metadata();
const result: ProcessResult = {
metadata: {
width: metadata.width,
height: metadata.height,
format: metadata.format,
hasAlpha: metadata.hasAlpha,
},
};
// Generate variants
const basePath = originalKey.replace(/^originals\//, 'processed/').replace(/\.[^.]+$/, '');
// Thumbnail
const thumbKey = `${basePath}/thumb.webp`;
const thumbBuffer = await sharp(originalBuffer)
.resize(IMAGE_VARIANTS.thumbnail.width, IMAGE_VARIANTS.thumbnail.height, {
fit: IMAGE_VARIANTS.thumbnail.fit,
})
.webp({ quality: 80 })
.toBuffer();
await this.storage.upload(thumbKey, thumbBuffer, 'image/webp');
result.thumbnail = thumbKey;
// Medium (only if original is larger)
if (
(metadata.width || 0) > IMAGE_VARIANTS.medium.width ||
(metadata.height || 0) > IMAGE_VARIANTS.medium.height
) {
const mediumKey = `${basePath}/medium.webp`;
const mediumBuffer = await sharp(originalBuffer)
.resize(IMAGE_VARIANTS.medium.width, IMAGE_VARIANTS.medium.height, {
fit: IMAGE_VARIANTS.medium.fit,
withoutEnlargement: true,
})
.webp({ quality: 85 })
.toBuffer();
await this.storage.upload(mediumKey, mediumBuffer, 'image/webp');
result.medium = mediumKey;
}
// Large (only if original is larger)
if (
(metadata.width || 0) > IMAGE_VARIANTS.large.width ||
(metadata.height || 0) > IMAGE_VARIANTS.large.height
) {
const largeKey = `${basePath}/large.webp`;
const largeBuffer = await sharp(originalBuffer)
.resize(IMAGE_VARIANTS.large.width, IMAGE_VARIANTS.large.height, {
fit: IMAGE_VARIANTS.large.fit,
withoutEnlargement: true,
})
.webp({ quality: 90 })
.toBuffer();
await this.storage.upload(largeKey, largeBuffer, 'image/webp');
result.large = largeKey;
}
return result;
}
async generateThumbnail(
buffer: Buffer,
options?: { width?: number; height?: number }
): Promise<Buffer> {
const width = options?.width || IMAGE_VARIANTS.thumbnail.width;
const height = options?.height || IMAGE_VARIANTS.thumbnail.height;
return sharp(buffer).resize(width, height, { fit: 'cover' }).webp({ quality: 80 }).toBuffer();
}
async transformImage(
buffer: Buffer,
options: {
width?: number;
height?: number;
fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
format?: 'webp' | 'jpeg' | 'png' | 'avif';
quality?: number;
}
): Promise<Buffer> {
let pipeline = sharp(buffer);
if (options.width || options.height) {
pipeline = pipeline.resize(options.width, options.height, {
fit: options.fit || 'inside',
withoutEnlargement: true,
});
}
const format = options.format || 'webp';
const quality = options.quality || 85;
switch (format) {
case 'webp':
pipeline = pipeline.webp({ quality });
break;
case 'jpeg':
pipeline = pipeline.jpeg({ quality });
break;
case 'png':
pipeline = pipeline.png();
break;
case 'avif':
pipeline = pipeline.avif({ quality });
break;
}
return pipeline.toBuffer();
}
}

View file

@ -0,0 +1,63 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { ProcessService } from './process.service';
import { UploadService } from '../upload/upload.service';
import { PROCESS_QUEUE, SUPPORTED_IMAGE_TYPES } from './process.constants';
interface ProcessJobData {
mediaId: string;
mimeType: string;
originalKey: string;
}
@Processor(PROCESS_QUEUE)
export class ProcessWorker extends WorkerHost {
constructor(
private processService: ProcessService,
private uploadService: UploadService
) {
super();
}
async process(job: Job<ProcessJobData>): Promise<void> {
const { mediaId, mimeType, originalKey } = job.data;
console.log(`Processing media ${mediaId} (${mimeType})`);
try {
if (SUPPORTED_IMAGE_TYPES.includes(mimeType)) {
await this.processImage(mediaId, originalKey, mimeType);
} else {
// For unsupported types, just mark as ready
await this.uploadService.update(mediaId, { status: 'ready' });
}
} catch (error) {
console.error(`Failed to process media ${mediaId}:`, error);
await this.uploadService.update(mediaId, { status: 'failed' });
throw error;
}
}
private async processImage(
mediaId: string,
originalKey: string,
mimeType: string
): Promise<void> {
const result = await this.processService.processImage(mediaId, originalKey, mimeType);
await this.uploadService.update(mediaId, {
status: 'ready',
keys: {
original: originalKey,
thumbnail: result.thumbnail,
medium: result.medium,
large: result.large,
},
metadata: result.metadata,
});
console.log(
`Processed image ${mediaId}: thumbnail=${!!result.thumbnail}, medium=${!!result.medium}, large=${!!result.large}`
);
}
}