mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-26 18:37:43 +02:00
✨ 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:
parent
5c8120f437
commit
cd28a83007
28 changed files with 5318 additions and 0 deletions
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue