diff --git a/.env.example b/.env.example index 38fc72a..089acea 100644 --- a/.env.example +++ b/.env.example @@ -58,8 +58,15 @@ S3_ENDPOINT= S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= S3_FORCE_PATH_STYLE=false +IMAGE_PROCESSING_ENABLED=true +IMAGE_PROCESSING_FFMPEG_PATH=ffmpeg +IMAGE_PROCESSING_LOW_WIDTH=360 +IMAGE_PROCESSING_MEDIUM_WIDTH=720 +IMAGE_PROCESSING_HIGH_WIDTH=1280 +IMAGE_PROCESSING_QUALITY=78 VIDEO_PROCESSING_ENABLED=false VIDEO_PROCESSING_FFMPEG_PATH=ffmpeg +VIDEO_PROCESSING_FFPROBE_PATH=ffprobe VIDEO_PROCESSING_MAX_WIDTH=1280 VIDEO_PROCESSING_MAX_FPS=30 VIDEO_PROCESSING_CRF=28 diff --git a/docs/FRONTEND_INTEGRATION.md b/docs/FRONTEND_INTEGRATION.md index 2cf3ba8..fc6fe46 100644 --- a/docs/FRONTEND_INTEGRATION.md +++ b/docs/FRONTEND_INTEGRATION.md @@ -18,7 +18,7 @@ CORS_ORIGINS=http://192.168.1.12:3000,http://192.168.1.12:5173 GOOGLE_CALLBACK_URL=http://192.168.1.12:4000/api/v1/auth/google/callback ``` -With `PUBLIC_BASE_URL` configured, file fields such as `avatar`, `coverImage`, `imageUrls`, `videoUrl`, `hlsUrl`, `audioUrl`, `thumbnailUrl`, `mediaUrl`, and marketplace images are returned as absolute URLs. +With `PUBLIC_BASE_URL` configured, file fields such as `avatar`, `coverImage`, `imageUrls`, `imageVariants`, `videoUrl`, `hlsUrl`, `audioUrl`, `thumbnailUrl`, `thumbnailVariants`, `mediaUrl`, and marketplace images are returned as absolute URLs. ## Pagination contract @@ -212,8 +212,10 @@ When `VIDEO_PROCESSING_ENABLED=true` and `ffmpeg` is available on the server: - uploaded post/reel videos are converted to optimized `mp4` - `+faststart` is applied so playback begins faster on mobile/web - uploaded post/reel videos also produce an HLS playlist at `hlsUrl` when `VIDEO_PROCESSING_GENERATE_HLS=true` +- when `ffprobe` is available, `hlsUrl` points to a master playlist with multiple renditions so HLS players can downgrade quality automatically on weak networks - local storage responses serve `mp4`, `m3u8`, `m4s`, and `ts` files with explicit media `Content-Type` headers and `Accept-Ranges: bytes` - a thumbnail image is generated automatically if the client does not send `thumbnailUrl` +- generated thumbnails also expose `thumbnailVariants.lowUrl`, `thumbnailVariants.mediumUrl`, `thumbnailVariants.highUrl`, and `thumbnailVariants.originalUrl` If `ffmpeg` is not installed or video processing is disabled, uploads still work and the original video file is stored as-is. @@ -222,6 +224,7 @@ Recommended client behavior: - use `hlsUrl` first when present for adaptive/streaming playback - fall back to `videoUrl` for progressive `mp4` playback +- for poster frames on weak networks, prefer `thumbnailVariants.lowUrl` or `thumbnailVariants.mediumUrl` ## Audio and image delivery @@ -229,11 +232,15 @@ Managed uploads are stored under stable UUID-based paths, so local storage respo - images (`jpg`, `jpeg`, `png`, `webp`, `gif`) are served with explicit `Content-Type` and long-lived immutable `Cache-Control` - audio (`mp3`, `wav`, `m4a`, `aac`, `ogg`) is served with explicit `Content-Type`, long-lived immutable `Cache-Control`, and `Accept-Ranges: bytes` +- uploaded post images are normalized into responsive variants and posts now include `imageVariants[]` Recommended client behavior: - for audio, stream `audioUrl` directly and allow byte-range playback/resume -- for images, render `imageUrls` directly and rely on URL-based caching for repeat views +- `imageUrls` now default to feed-friendly optimized images for backward compatibility +- on weak networks, prefer `imageVariants[index].lowUrl` +- on normal networks, use `imageUrls[index]` or `imageVariants[index].mediumUrl` +- on detail screens or zoom views, use `imageVariants[index].highUrl` or `imageVariants[index].originalUrl` ## Marketplace split diff --git a/src/common/utils/public-url.util.spec.ts b/src/common/utils/public-url.util.spec.ts index 5cc6c64..d701cec 100644 --- a/src/common/utils/public-url.util.spec.ts +++ b/src/common/utils/public-url.util.spec.ts @@ -1,4 +1,9 @@ -import { resolveManagedFileUrl, resolveManagedFileUrls } from './public-url.util'; +import { + resolveManagedFileUrl, + resolveManagedFileUrlRecord, + resolveManagedFileUrlRecords, + resolveManagedFileUrls, +} from './public-url.util'; describe('public url util', () => { const originalPublicBaseUrl = process.env.PUBLIC_BASE_URL; @@ -26,4 +31,34 @@ describe('public url util', () => { 'http://192.168.1.12:4000/uploads/b.png', ]); }); + + it('resolves url objects used for media variants', () => { + expect( + resolveManagedFileUrlRecord({ + lowUrl: '/uploads/posts/images/image-1/low.webp', + mediumUrl: '/uploads/posts/images/image-1/medium.webp', + externalUrl: 'https://cdn.example.com/image.webp', + }), + ).toEqual({ + lowUrl: 'http://192.168.1.12:4000/uploads/posts/images/image-1/low.webp', + mediumUrl: 'http://192.168.1.12:4000/uploads/posts/images/image-1/medium.webp', + externalUrl: 'https://cdn.example.com/image.webp', + }); + }); + + it('resolves arrays of media variant objects', () => { + expect( + resolveManagedFileUrlRecords([ + { + lowUrl: '/uploads/posts/images/image-1/low.webp', + mediumUrl: '/uploads/posts/images/image-1/medium.webp', + }, + ]), + ).toEqual([ + { + lowUrl: 'http://192.168.1.12:4000/uploads/posts/images/image-1/low.webp', + mediumUrl: 'http://192.168.1.12:4000/uploads/posts/images/image-1/medium.webp', + }, + ]); + }); }); diff --git a/src/common/utils/public-url.util.ts b/src/common/utils/public-url.util.ts index ea260f1..f03fe68 100644 --- a/src/common/utils/public-url.util.ts +++ b/src/common/utils/public-url.util.ts @@ -25,3 +25,21 @@ export const resolveManagedFileUrls = (fileUrls: unknown): unknown => { return fileUrls.map((fileUrl) => resolveManagedFileUrl(fileUrl)); }; + +export const resolveManagedFileUrlRecord = (value: unknown): unknown => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return value; + } + + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [key, resolveManagedFileUrl(entryValue)]), + ); +}; + +export const resolveManagedFileUrlRecords = (value: unknown): unknown => { + if (!Array.isArray(value)) { + return value; + } + + return value.map((entry) => resolveManagedFileUrlRecord(entry)); +}; diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 56c3d0e..09f64db 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -94,9 +94,24 @@ export default () => ({ (process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true', }, }, + imageProcessing: { + enabled: + typeof process.env.IMAGE_PROCESSING_ENABLED === 'string' + ? process.env.IMAGE_PROCESSING_ENABLED.toLowerCase() === 'true' + : (process.env.VIDEO_PROCESSING_ENABLED ?? 'false').toLowerCase() === 'true', + ffmpegPath: + process.env.IMAGE_PROCESSING_FFMPEG_PATH ?? + process.env.VIDEO_PROCESSING_FFMPEG_PATH ?? + 'ffmpeg', + lowWidth: Number(process.env.IMAGE_PROCESSING_LOW_WIDTH ?? 360), + mediumWidth: Number(process.env.IMAGE_PROCESSING_MEDIUM_WIDTH ?? 720), + highWidth: Number(process.env.IMAGE_PROCESSING_HIGH_WIDTH ?? 1280), + quality: Number(process.env.IMAGE_PROCESSING_QUALITY ?? 78), + }, videoProcessing: { enabled: (process.env.VIDEO_PROCESSING_ENABLED ?? 'false').toLowerCase() === 'true', ffmpegPath: process.env.VIDEO_PROCESSING_FFMPEG_PATH ?? 'ffmpeg', + ffprobePath: process.env.VIDEO_PROCESSING_FFPROBE_PATH ?? '', maxWidth: Number(process.env.VIDEO_PROCESSING_MAX_WIDTH ?? 1280), maxFps: Number(process.env.VIDEO_PROCESSING_MAX_FPS ?? 30), crf: Number(process.env.VIDEO_PROCESSING_CRF ?? 28), diff --git a/src/config/validation.schema.ts b/src/config/validation.schema.ts index 4f57a55..058ce12 100644 --- a/src/config/validation.schema.ts +++ b/src/config/validation.schema.ts @@ -61,8 +61,15 @@ export const validationSchema = Joi.object({ S3_ACCESS_KEY_ID: Joi.string().allow('').optional(), S3_SECRET_ACCESS_KEY: Joi.string().allow('').optional(), S3_FORCE_PATH_STYLE: Joi.boolean().truthy('true').falsy('false').default(false), + IMAGE_PROCESSING_ENABLED: Joi.boolean().truthy('true').falsy('false').optional(), + IMAGE_PROCESSING_FFMPEG_PATH: Joi.string().allow('').optional(), + IMAGE_PROCESSING_LOW_WIDTH: Joi.number().min(160).max(1920).default(360), + IMAGE_PROCESSING_MEDIUM_WIDTH: Joi.number().min(160).max(2560).default(720), + IMAGE_PROCESSING_HIGH_WIDTH: Joi.number().min(320).max(3840).default(1280), + IMAGE_PROCESSING_QUALITY: Joi.number().min(40).max(100).default(78), VIDEO_PROCESSING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false), VIDEO_PROCESSING_FFMPEG_PATH: Joi.string().default('ffmpeg'), + VIDEO_PROCESSING_FFPROBE_PATH: Joi.string().allow('').optional(), VIDEO_PROCESSING_MAX_WIDTH: Joi.number().min(320).max(3840).default(1280), VIDEO_PROCESSING_MAX_FPS: Joi.number().min(12).max(60).default(30), VIDEO_PROCESSING_CRF: Joi.number().min(18).max(35).default(28), diff --git a/src/infrastructure/storage/image-processing.service.ts b/src/infrastructure/storage/image-processing.service.ts new file mode 100644 index 0000000..7e41f9c --- /dev/null +++ b/src/infrastructure/storage/image-processing.service.ts @@ -0,0 +1,245 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { spawn } from 'child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { extname, join } from 'path'; + +export type UploadedImageFile = { + mimetype?: string; + size: number; + buffer: Buffer; + originalname?: string; +}; + +export type ProcessedImageVariantName = 'original' | 'low' | 'medium' | 'high'; + +export type ProcessedImageResult = { + primaryVariantName: ProcessedImageVariantName; + variants: Array<{ + name: ProcessedImageVariantName; + relativePath: string; + extension: string; + buffer: Buffer; + contentType: string; + }>; +}; + +@Injectable() +export class ImageProcessingService { + private readonly logger = new Logger(ImageProcessingService.name); + private ffmpegAvailabilityResolved = false; + private ffmpegAvailable = false; + + constructor(private readonly configService: ConfigService) {} + + async processForResponsiveDelivery(file: UploadedImageFile): Promise { + const originalVariant: ProcessedImageResult['variants'][number] = { + name: 'original', + relativePath: `original${this.resolveInputExtension(file)}`, + extension: this.resolveInputExtension(file), + buffer: file.buffer, + contentType: this.resolveOriginalContentType(file), + }; + + if (!this.isEnabled()) { + return { + primaryVariantName: 'original', + variants: [originalVariant], + }; + } + + const ffmpegReady = await this.ensureFfmpegAvailable(); + if (!ffmpegReady) { + return { + primaryVariantName: 'original', + variants: [originalVariant], + }; + } + + const workingDir = await mkdtemp(join(tmpdir(), 'oudelaa-image-')); + const inputPath = join(workingDir, `input${this.resolveInputExtension(file)}`); + + try { + await writeFile(inputPath, file.buffer); + + const lowVariant = await this.generateWebpVariant(inputPath, workingDir, 'low', this.getLowWidth()); + const mediumVariant = await this.generateWebpVariant( + inputPath, + workingDir, + 'medium', + this.getMediumWidth(), + ); + const highVariant = await this.generateWebpVariant(inputPath, workingDir, 'high', this.getHighWidth()); + + return { + primaryVariantName: 'medium', + variants: [originalVariant, lowVariant, mediumVariant, highVariant], + }; + } catch (error) { + this.logger.warn( + `Image optimization failed for "${file.originalname ?? 'upload'}": ${ + error instanceof Error ? error.message : 'unknown error' + }`, + ); + + return { + primaryVariantName: 'original', + variants: [originalVariant], + }; + } finally { + await rm(workingDir, { recursive: true, force: true }); + } + } + + private isEnabled(): boolean { + const explicit = this.configService.get('imageProcessing.enabled', { infer: true }); + if (typeof explicit === 'boolean') { + return explicit; + } + + return this.configService.get('videoProcessing.enabled', { infer: true }) ?? false; + } + + private getFfmpegPath(): string { + return ( + this.configService.get('imageProcessing.ffmpegPath', { infer: true }) ?? + this.configService.get('videoProcessing.ffmpegPath', { infer: true }) ?? + 'ffmpeg' + ).trim(); + } + + private getLowWidth(): number { + return this.configService.get('imageProcessing.lowWidth', { infer: true }) ?? 360; + } + + private getMediumWidth(): number { + return this.configService.get('imageProcessing.mediumWidth', { infer: true }) ?? 720; + } + + private getHighWidth(): number { + return this.configService.get('imageProcessing.highWidth', { infer: true }) ?? 1280; + } + + private getQuality(): number { + return this.configService.get('imageProcessing.quality', { infer: true }) ?? 78; + } + + private resolveInputExtension(file: UploadedImageFile): string { + const extension = extname(file.originalname ?? '').toLowerCase(); + if (extension) { + return extension; + } + + switch (file.mimetype) { + case 'image/jpeg': + return '.jpg'; + case 'image/png': + return '.png'; + case 'image/webp': + return '.webp'; + case 'image/gif': + return '.gif'; + default: + return '.jpg'; + } + } + + private resolveOriginalContentType(file: UploadedImageFile): string { + if (file.mimetype?.trim()) { + return file.mimetype; + } + + switch (this.resolveInputExtension(file)) { + case '.png': + return 'image/png'; + case '.webp': + return 'image/webp'; + case '.gif': + return 'image/gif'; + case '.jpg': + case '.jpeg': + default: + return 'image/jpeg'; + } + } + + private async generateWebpVariant( + inputPath: string, + workingDir: string, + name: Exclude, + width: number, + ): Promise { + const outputPath = join(workingDir, `${name}.webp`); + + await this.runFfmpeg([ + '-y', + '-i', + inputPath, + '-frames:v', + '1', + '-vf', + `scale='min(${width},iw)':-2:force_original_aspect_ratio=decrease`, + '-c:v', + 'libwebp', + '-compression_level', + '6', + '-q:v', + String(this.getQuality()), + outputPath, + ]); + + return { + name, + relativePath: `${name}.webp`, + extension: '.webp', + buffer: await readFile(outputPath), + contentType: 'image/webp', + }; + } + + private async ensureFfmpegAvailable(): Promise { + if (this.ffmpegAvailabilityResolved) { + return this.ffmpegAvailable; + } + + try { + await this.runFfmpeg(['-version']); + this.ffmpegAvailable = true; + } catch (error) { + this.ffmpegAvailable = false; + this.logger.warn( + `Image processing is enabled, but ffmpeg is unavailable at "${this.getFfmpegPath()}": ${ + error instanceof Error ? error.message : 'unknown error' + }`, + ); + } + + this.ffmpegAvailabilityResolved = true; + return this.ffmpegAvailable; + } + + private async runFfmpeg(args: string[]): Promise { + await new Promise((resolve, reject) => { + const child = spawn(this.getFfmpegPath(), args, { windowsHide: true }); + let stderr = ''; + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + + reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`)); + }); + }); + } +} diff --git a/src/infrastructure/storage/storage.module.ts b/src/infrastructure/storage/storage.module.ts index 3fa40c7..ff5c516 100644 --- a/src/infrastructure/storage/storage.module.ts +++ b/src/infrastructure/storage/storage.module.ts @@ -1,10 +1,11 @@ import { Global, Module } from '@nestjs/common'; +import { ImageProcessingService } from './image-processing.service'; import { ManagedStorageService } from './managed-storage.service'; import { VideoProcessingService } from './video-processing.service'; @Global() @Module({ - providers: [ManagedStorageService, VideoProcessingService], - exports: [ManagedStorageService, VideoProcessingService], + providers: [ManagedStorageService, VideoProcessingService, ImageProcessingService], + exports: [ManagedStorageService, VideoProcessingService, ImageProcessingService], }) export class StorageModule {} diff --git a/src/infrastructure/storage/video-processing.service.ts b/src/infrastructure/storage/video-processing.service.ts index 015b5a9..6cb9977 100644 --- a/src/infrastructure/storage/video-processing.service.ts +++ b/src/infrastructure/storage/video-processing.service.ts @@ -30,6 +30,19 @@ export type OptimizedVideoResult = { }; }; +type ProbedVideoInfo = { + hasAudio: boolean; + width: number; +}; + +type HlsRendition = { + width: number; + videoBitrateKbps: number; + maxRateKbps: number; + bufSizeKbps: number; + audioBitrateKbps: number; +}; + @Injectable() export class VideoProcessingService { private readonly logger = new Logger(VideoProcessingService.name); @@ -180,6 +193,24 @@ export class VideoProcessingService { ).trim(); } + private getFfprobePath(): string { + const configured = ( + this.configService.get('videoProcessing.ffprobePath', { infer: true }) ?? '' + ).trim(); + if (configured) { + return configured; + } + + const ffmpegPath = this.getFfmpegPath(); + if (/ffmpeg(?:\.exe)?$/i.test(ffmpegPath)) { + return ffmpegPath.replace(/ffmpeg(?:\.exe)?$/i, (match) => + match.toLowerCase().endsWith('.exe') ? 'ffprobe.exe' : 'ffprobe', + ); + } + + return 'ffprobe'; + } + private getMaxWidth(): number { return this.configService.get('videoProcessing.maxWidth', { infer: true }) ?? 1280; } @@ -257,6 +288,112 @@ export class VideoProcessingService { ): Promise> { await mkdir(hlsDir, { recursive: true }); + let probe: ProbedVideoInfo | null = null; + try { + probe = await this.probeVideo(optimizedMp4Path); + } catch (error) { + this.logger.warn( + `Video probe failed before adaptive HLS generation: ${ + error instanceof Error ? error.message : 'unknown error' + }`, + ); + } + + const renditions = this.buildHlsRenditions(probe?.width ?? this.getMaxWidth()); + if (!probe || renditions.length <= 1) { + return this.generateSingleRenditionHlsPackage(optimizedMp4Path, hlsDir); + } + + for (let index = 0; index < renditions.length; index += 1) { + await mkdir(join(hlsDir, `stream_${index}`), { recursive: true }); + } + + const args = ['-y', '-i', optimizedMp4Path]; + args.push('-filter_complex', this.buildAdaptiveHlsFilterComplex(renditions)); + + for (let index = 0; index < renditions.length; index += 1) { + args.push('-map', `[v${index}out]`); + if (probe.hasAudio) { + args.push('-map', '0:a:0'); + } + } + + renditions.forEach((rendition, index) => { + args.push( + `-c:v:${index}`, + 'libx264', + `-b:v:${index}`, + `${rendition.videoBitrateKbps}k`, + `-maxrate:v:${index}`, + `${rendition.maxRateKbps}k`, + `-bufsize:v:${index}`, + `${rendition.bufSizeKbps}k`, + `-g:v:${index}`, + String(this.getMaxFps() * 2), + `-keyint_min:v:${index}`, + String(this.getMaxFps() * 2), + `-sc_threshold:v:${index}`, + '0', + `-preset:v:${index}`, + this.getPreset(), + ); + + if (probe.hasAudio) { + args.push( + `-c:a:${index}`, + 'aac', + `-b:a:${index}`, + `${rendition.audioBitrateKbps}k`, + `-ac:a:${index}`, + '2', + `-ar:a:${index}`, + '44100', + ); + } + }); + + args.push( + '-f', + 'hls', + '-hls_time', + String(this.getHlsSegmentDurationSeconds()), + '-hls_playlist_type', + 'vod', + '-hls_list_size', + '0', + '-hls_flags', + 'independent_segments', + '-hls_segment_type', + 'fmp4', + '-master_pl_name', + 'master.m3u8', + '-hls_fmp4_init_filename', + 'stream_%v/init.mp4', + '-hls_segment_filename', + join(hlsDir, 'stream_%v/segment-%03d.m4s'), + '-var_stream_map', + probe.hasAudio + ? renditions.map((_, index) => `v:${index},a:${index}`).join(' ') + : renditions.map((_, index) => `v:${index}`).join(' '), + join(hlsDir, 'stream_%v/playlist.m3u8'), + ); + + await this.runFfmpeg(args); + + const files = await this.readFilesRecursively(hlsDir); + + return { + playlistRelativePath: 'master.m3u8', + files, + }; + } + + private async generateSingleRenditionHlsPackage( + optimizedMp4Path: string, + hlsDir: string, + ): Promise> { + await mkdir(hlsDir, { recursive: true }); + await this.runFfmpeg([ '-y', '-i', @@ -288,14 +425,7 @@ export class VideoProcessingService { join(hlsDir, 'playlist.m3u8'), ]); - const fileNames = (await readdir(hlsDir)).sort((left, right) => left.localeCompare(right)); - const files = await Promise.all( - fileNames.map(async (fileName) => ({ - relativePath: fileName, - buffer: await readFile(join(hlsDir, fileName)), - contentType: this.resolveStreamingContentType(fileName), - })), - ); + const files = await this.readFilesRecursively(hlsDir); return { playlistRelativePath: 'playlist.m3u8', @@ -303,6 +433,119 @@ export class VideoProcessingService { }; } + private buildAdaptiveHlsFilterComplex(renditions: HlsRendition[]): string { + if (renditions.length === 1) { + return `[0:v]${this.buildAdaptiveScaleFilter(renditions[0].width)}[v0out]`; + } + + const splitOutputs = renditions.map((_, index) => `[v${index}]`).join(''); + const split = `[0:v]split=${renditions.length}${splitOutputs}`; + const transforms = renditions.map( + (rendition, index) => + `[v${index}]${this.buildAdaptiveScaleFilter(rendition.width)}[v${index}out]`, + ); + + return [split, ...transforms].join(';'); + } + + private buildAdaptiveScaleFilter(width: number): string { + return `scale='min(${width},iw)':-2:force_original_aspect_ratio=decrease,fps=${this.getMaxFps()},format=yuv420p`; + } + + private buildHlsRenditions(sourceWidth: number): HlsRendition[] { + const requestedWidths = [Math.min(480, this.getMaxWidth()), Math.min(720, this.getMaxWidth()), this.getMaxWidth()]; + const widths = Array.from( + new Set( + requestedWidths + .map((width) => Math.max(240, Math.min(width, sourceWidth))) + .filter((width) => Number.isFinite(width)), + ), + ).sort((left, right) => left - right); + + return widths.map((width) => this.resolveHlsRendition(width)); + } + + private resolveHlsRendition(width: number): HlsRendition { + if (width <= 480) { + return { + width, + videoBitrateKbps: 800, + maxRateKbps: 960, + bufSizeKbps: 1600, + audioBitrateKbps: 96, + }; + } + + if (width <= 720) { + return { + width, + videoBitrateKbps: 1600, + maxRateKbps: 1920, + bufSizeKbps: 3200, + audioBitrateKbps: 128, + }; + } + + return { + width, + videoBitrateKbps: 2800, + maxRateKbps: 3360, + bufSizeKbps: 5600, + audioBitrateKbps: 128, + }; + } + + private async probeVideo(inputPath: string): Promise { + const stdout = await this.runCommand(this.getFfprobePath(), [ + '-v', + 'error', + '-show_entries', + 'stream=codec_type,width', + '-of', + 'json', + inputPath, + ]); + + const parsed = JSON.parse(stdout) as { + streams?: Array<{ codec_type?: string; width?: number }>; + }; + const streams = parsed.streams ?? []; + const videoStream = streams.find((stream) => stream.codec_type === 'video'); + if (!videoStream?.width) { + throw new Error('ffprobe did not return a video width'); + } + + return { + width: videoStream.width, + hasAudio: streams.some((stream) => stream.codec_type === 'audio'), + }; + } + + private async readFilesRecursively( + baseDir: string, + currentDir = baseDir, + ): Promise> { + const entries = await readdir(currentDir, { withFileTypes: true }); + const files: Array<{ relativePath: string; buffer: Buffer; contentType: string }> = []; + + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const absolutePath = join(currentDir, entry.name); + if (entry.isDirectory()) { + files.push(...(await this.readFilesRecursively(baseDir, absolutePath))); + continue; + } + + const relativePath = absolutePath.slice(baseDir.length + 1).replace(/\\/g, '/'); + files.push({ + relativePath, + buffer: await readFile(absolutePath), + contentType: this.resolveStreamingContentType(relativePath), + }); + } + + return files; + } + private resolveStreamingContentType(fileName: string): string { const extension = extname(fileName).toLowerCase(); @@ -341,11 +584,16 @@ export class VideoProcessingService { return this.ffmpegAvailable; } - private async runFfmpeg(args: string[]): Promise { - await new Promise((resolve, reject) => { - const child = spawn(this.getFfmpegPath(), args, { windowsHide: true }); + private async runCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { windowsHide: true }); + let stdout = ''; let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); @@ -356,12 +604,16 @@ export class VideoProcessingService { child.on('close', (code) => { if (code === 0) { - resolve(); + resolve(stdout); return; } - reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`)); + reject(new Error(stderr.trim() || `${command} exited with code ${code ?? 'unknown'}`)); }); }); } + + private async runFfmpeg(args: string[]): Promise { + await this.runCommand(this.getFfmpegPath(), args); + } } diff --git a/src/modules/posts/posts.service.ts b/src/modules/posts/posts.service.ts index fbcfd9b..4f5f72a 100644 --- a/src/modules/posts/posts.service.ts +++ b/src/modules/posts/posts.service.ts @@ -12,6 +12,10 @@ import { normalizeWaveformPeaks, } from '../../common/utils/waveform.util'; import { FeedVersionService } from '../../infrastructure/cache/feed-version.service'; +import { + ImageProcessingService, + UploadedImageFile, +} from '../../infrastructure/storage/image-processing.service'; import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; import { UploadedVideoFile, @@ -26,7 +30,7 @@ import { CreatePostDto } from './dto/create-post.dto'; import { PostQueryDto } from './dto/post-query.dto'; import { ReelQueryDto } from './dto/reel-query.dto'; import { UpdatePostDto } from './dto/update-post.dto'; -import { PostDocument } from './schemas/post.schema'; +import { PostDocument, PostMediaVariantSet } from './schemas/post.schema'; import { PostsRepository } from './posts.repository'; type PostMediaMetadataInput = Pick< @@ -52,6 +56,12 @@ type SavedVideoUpload = { videoUrl: string; hlsUrl: string; thumbnailUrl: string; + thumbnailVariants: PostMediaVariantSet | null; +}; + +type SavedImageUpload = { + primaryUrl: string; + variants: PostMediaVariantSet; }; @Injectable() @@ -62,6 +72,7 @@ export class PostsService { private readonly postsRepository: PostsRepository, private readonly usersRepository: UsersRepository, private readonly storageService: ManagedStorageService, + private readonly imageProcessingService: ImageProcessingService, private readonly videoProcessingService: VideoProcessingService, private readonly feedVersionService: FeedVersionService, private readonly notificationsService: NotificationsService, @@ -98,13 +109,17 @@ export class PostsService { throw new BadRequestException('Post can contain either images or audio, not both'); } - const uploadedImageUrls = await this.saveImageFiles(imageFiles); + const savedImageUploads = await this.saveImageFiles(imageFiles); + const uploadedImageUrls = savedImageUploads.map((item) => item.primaryUrl); + const uploadedImageVariants = savedImageUploads.map((item) => item.variants); const savedVideoUpload = videoFile ? await this.saveVideoUpload(videoFile) : null; const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? ''; const uploadedHlsUrl = savedVideoUpload?.hlsUrl ?? ''; const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? ''; + const uploadedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null; const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : ''; const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls; + const finalImageVariants = uploadedImageVariants.length ? uploadedImageVariants : []; const finalVideoUrl = uploadedVideoUrl || dto.videoUrl || ''; const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || ''; const finalContent = dto.content?.trim() ?? ''; @@ -128,9 +143,11 @@ export class PostsService { post = await this.postsRepository.create(userId, { content: finalContent, imageUrls: finalImageUrls, + imageVariants: finalImageVariants, videoUrl: finalVideoUrl, hlsUrl: uploadedHlsUrl, audioUrl: finalAudioUrl, + thumbnailVariants: uploadedThumbnailVariants, taggedUserIds, mentionUsernames: mentionResolution.mentionUsernames, location, @@ -143,10 +160,12 @@ export class PostsService { }); } catch (error) { await Promise.all([ - ...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)), + ...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)), uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(), - uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(), + uploadedThumbnailUrl + ? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants) + : Promise.resolve(), uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(), ]); throw error; @@ -199,13 +218,21 @@ export class PostsService { throw new BadRequestException('Post can contain either images or audio, not both'); } - const uploadedImageUrls = await this.saveImageFiles(imageFiles); + const savedImageUploads = await this.saveImageFiles(imageFiles); + const uploadedImageUrls = savedImageUploads.map((item) => item.primaryUrl); + const uploadedImageVariants = savedImageUploads.map((item) => item.variants); const savedVideoUpload = videoFile ? await this.saveVideoUpload(videoFile) : null; const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? ''; const uploadedHlsUrl = savedVideoUpload?.hlsUrl ?? ''; const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? ''; + const uploadedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null; const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : ''; const hasImageUpdate = typeof dto.imageUrls !== 'undefined' || imageFiles.length > 0; + const existingImageVariants = Array.isArray((post as any).imageVariants) + ? (((post as any).imageVariants ?? []) as PostMediaVariantSet[]) + : []; + const existingThumbnailVariants = + ((post as any).thumbnailVariants as PostMediaVariantSet | null | undefined) ?? null; const hasVideoUpdate = typeof dto.videoUrl !== 'undefined' || !!videoFile; const hasAudioUpdate = typeof dto.audioUrl !== 'undefined' || !!audioFile; @@ -214,6 +241,11 @@ export class PostsService { ? uploadedImageUrls : inputImageUrls : post.imageUrls ?? []; + const nextImageVariants = hasImageUpdate + ? imageFiles.length + ? uploadedImageVariants + : [] + : existingImageVariants; const nextVideoUrl = hasVideoUpdate ? videoFile @@ -226,6 +258,11 @@ export class PostsService { ? uploadedAudioUrl : dto.audioUrl ?? '' : post.audioUrl ?? ''; + const nextThumbnailVariants = videoFile + ? uploadedThumbnailVariants + : typeof dto.thumbnailUrl === 'string' + ? null + : existingThumbnailVariants; const nextPostType = this.resolvePostType(nextImageUrls, nextVideoUrl, nextAudioUrl); const nextContent = typeof dto.content === 'string' ? dto.content.trim() : post.content ?? ''; const nextTaggedUserIds = @@ -272,7 +309,9 @@ export class PostsService { ...dto, content: nextContent, imageUrls: nextImageUrls, + imageVariants: nextImageVariants, hlsUrl: nextHlsUrl, + thumbnailVariants: nextThumbnailVariants, taggedUserIds: nextTaggedUserIds, mentionUsernames: mentionResolution.mentionUsernames, location: nextLocation, @@ -300,40 +339,51 @@ export class PostsService { payload.videoUrl = ''; payload.audioUrl = ''; payload.hlsUrl = ''; + payload.thumbnailVariants = null; } if (videoFile) { payload.videoUrl = uploadedVideoUrl; payload.hlsUrl = uploadedHlsUrl; + payload.thumbnailVariants = uploadedThumbnailVariants; payload.imageUrls = []; + payload.imageVariants = []; payload.audioUrl = ''; } if (audioFile) { payload.audioUrl = uploadedAudioUrl; payload.imageUrls = []; + payload.imageVariants = []; payload.videoUrl = ''; payload.hlsUrl = ''; } + if (nextPostType !== PostType.AUDIO && nextPostType !== PostType.VIDEO) { + payload.thumbnailVariants = null; + } let updated: PostDocument | null; try { updated = await this.postsRepository.updateById(postId, payload as any); } catch (error) { await Promise.all([ - ...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)), + ...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)), uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(), - uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(), + uploadedThumbnailUrl + ? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants) + : Promise.resolve(), uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(), ]); throw error; } if (!updated) { await Promise.all([ - ...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)), + ...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)), uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(), - uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(), + uploadedThumbnailUrl + ? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants) + : Promise.resolve(), uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(), ]); throw new NotFoundException('Post not found'); @@ -346,17 +396,18 @@ export class PostsService { await this.storageService.deleteContainingDirectory(post.hlsUrl ?? ''); } if ((post.thumbnailUrl ?? '') !== (updated.thumbnailUrl ?? '')) { - await this.deleteManagedPostMedia(post.thumbnailUrl ?? ''); + await this.deleteThumbnailAsset(post.thumbnailUrl ?? '', existingThumbnailVariants); } if (hasAudioUpdate && (post.audioUrl ?? '') !== (updated.audioUrl ?? '')) { await this.deleteManagedPostMedia(post.audioUrl ?? ''); } if (hasImageUpdate) { const nextImageSet = new Set(updated.imageUrls ?? []); + const existingImageAssets = this.buildSavedImageAssets(post.imageUrls ?? [], existingImageVariants); await Promise.all( - (post.imageUrls ?? []) - .filter((url) => !nextImageSet.has(url)) - .map((url) => this.deleteManagedPostMedia(url)), + existingImageAssets + .filter((asset) => !nextImageSet.has(asset.primaryUrl)) + .map((asset) => this.deleteSavedImageAsset(asset)), ); } @@ -382,11 +433,19 @@ export class PostsService { } await this.postsRepository.deleteById(postId, userId); + const existingImageVariants = Array.isArray((post as any).imageVariants) + ? (((post as any).imageVariants ?? []) as PostMediaVariantSet[]) + : []; + const existingThumbnailVariants = + ((post as any).thumbnailVariants as PostMediaVariantSet | null | undefined) ?? null; await Promise.all([ - ...(post.imageUrls ?? []).map((url) => this.deleteManagedPostMedia(url)), + ...this.buildSavedImageAssets(post.imageUrls ?? [], existingImageVariants).map((asset) => + this.deleteSavedImageAsset(asset), + ), this.deleteManagedPostMedia(post.videoUrl ?? ''), this.storageService.deleteContainingDirectory(post.hlsUrl ?? ''), this.deleteManagedPostMedia(post.audioUrl ?? ''), + this.deleteThumbnailAsset(post.thumbnailUrl ?? '', existingThumbnailVariants), ]); await this.usersRepository.incrementPostsCount(userId, -1); await this.feedVersionService.bumpGlobalVersion(); @@ -623,12 +682,20 @@ export class PostsService { throw new NotFoundException('Post not found'); } + const existingImageVariants = Array.isArray((post as any).imageVariants) + ? (((post as any).imageVariants ?? []) as PostMediaVariantSet[]) + : []; + const existingThumbnailVariants = + ((post as any).thumbnailVariants as PostMediaVariantSet | null | undefined) ?? null; await this.postsRepository.deleteById(postId, superAdminIdentifier); await Promise.all([ - ...(post.imageUrls ?? []).map((url) => this.deleteManagedPostMedia(url)), + ...this.buildSavedImageAssets(post.imageUrls ?? [], existingImageVariants).map((asset) => + this.deleteSavedImageAsset(asset), + ), this.deleteManagedPostMedia(post.videoUrl ?? ''), this.storageService.deleteContainingDirectory(post.hlsUrl ?? ''), this.deleteManagedPostMedia(post.audioUrl ?? ''), + this.deleteThumbnailAsset(post.thumbnailUrl ?? '', existingThumbnailVariants), ]); const authorId = this.extractEntityId(post.authorId); if (authorId) { @@ -938,6 +1005,7 @@ export class PostsService { let videoUrl = ''; let hlsUrl = ''; let thumbnailUrl = ''; + let thumbnailVariants: PostMediaVariantSet | null = null; const hlsFolderName = `stream-${new Types.ObjectId().toString()}`; try { @@ -958,21 +1026,22 @@ export class PostsService { } if (optimized.generatedThumbnail) { - thumbnailUrl = await this.storageService.saveFile({ - folderSegments: ['posts', 'thumbnails'], - extension: optimized.generatedThumbnail.extension, + const savedThumbnail = await this.saveResponsiveImageAsset('thumbnails', { buffer: optimized.generatedThumbnail.buffer, - contentType: optimized.generatedThumbnail.contentType, - fileNamePrefix: 'thumbnail', + size: optimized.generatedThumbnail.buffer.length, + mimetype: optimized.generatedThumbnail.contentType, + originalname: `thumbnail${optimized.generatedThumbnail.extension}`, }); + thumbnailUrl = savedThumbnail.primaryUrl; + thumbnailVariants = savedThumbnail.variants; } - return { videoUrl, hlsUrl, thumbnailUrl }; + return { videoUrl, hlsUrl, thumbnailUrl, thumbnailVariants }; } catch (error) { await Promise.all([ videoUrl ? this.deleteManagedPostMedia(videoUrl) : Promise.resolve(), hlsUrl ? this.storageService.deleteContainingDirectory(hlsUrl) : Promise.resolve(), - thumbnailUrl ? this.deleteManagedPostMedia(thumbnailUrl) : Promise.resolve(), + thumbnailUrl ? this.deleteThumbnailAsset(thumbnailUrl, thumbnailVariants) : Promise.resolve(), ]); throw error; } @@ -1097,7 +1166,7 @@ export class PostsService { private async saveImageFiles( files: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>, - ): Promise { + ): Promise { if (!files.length) { return []; } @@ -1105,18 +1174,150 @@ export class PostsService { throw new BadRequestException('Post can contain up to 10 images'); } - const urls: string[] = []; + const uploads: SavedImageUpload[] = []; try { for (const file of files) { - urls.push(await this.saveMediaFile('image', file)); + uploads.push(await this.saveResponsiveImageAsset('images', file)); } - return urls; + return uploads; } catch (error) { - await Promise.all(urls.map((url) => this.deleteManagedPostMedia(url))); + await Promise.all(uploads.map((upload) => this.deleteSavedImageAsset(upload))); throw error; } } + private async saveResponsiveImageAsset( + folder: 'images' | 'thumbnails', + file: UploadedImageFile, + ): Promise { + this.validateMediaFile('image', file); + const processed = await this.imageProcessingService.processForResponsiveDelivery(file); + const groupName = `${folder.slice(0, -1)}-${new Types.ObjectId().toString()}`; + const savedFiles = await this.storageService.saveFiles({ + folderSegments: ['posts', folder, groupName], + files: processed.variants.map((variant) => ({ + relativePath: variant.relativePath, + buffer: variant.buffer, + contentType: variant.contentType, + })), + }); + + const variants = this.buildVariantSet(processed, savedFiles); + return { + primaryUrl: this.resolvePrimaryVariantUrl(variants, processed.primaryVariantName), + variants, + }; + } + + private buildVariantSet( + processed: Awaited>, + savedFiles: Record, + ): PostMediaVariantSet { + const byName = new Map( + processed.variants.map((variant) => [variant.name, savedFiles[variant.relativePath] ?? '']), + ); + const originalUrl = byName.get('original') ?? ''; + const lowUrl = byName.get('low') ?? originalUrl; + const mediumUrl = byName.get('medium') ?? byName.get('high') ?? lowUrl; + const highUrl = byName.get('high') ?? mediumUrl ?? lowUrl; + + return { + originalUrl, + lowUrl, + mediumUrl, + highUrl, + }; + } + + private resolvePrimaryVariantUrl( + variants: PostMediaVariantSet, + preferredVariantName: 'original' | 'low' | 'medium' | 'high', + ): string { + const candidates = + preferredVariantName === 'low' + ? [variants.lowUrl, variants.mediumUrl, variants.highUrl, variants.originalUrl] + : preferredVariantName === 'high' + ? [variants.highUrl, variants.mediumUrl, variants.lowUrl, variants.originalUrl] + : preferredVariantName === 'original' + ? [variants.originalUrl, variants.highUrl, variants.mediumUrl, variants.lowUrl] + : [variants.mediumUrl, variants.highUrl, variants.lowUrl, variants.originalUrl]; + + return candidates.find((candidate) => !!candidate) ?? ''; + } + + private buildSavedImageAssets( + imageUrls: string[], + imageVariants: PostMediaVariantSet[] = [], + ): SavedImageUpload[] { + return imageUrls.map((primaryUrl, index) => ({ + primaryUrl, + variants: imageVariants[index] ?? this.buildFlatVariantSet(primaryUrl), + })); + } + + private buildFlatVariantSet(primaryUrl: string): PostMediaVariantSet { + return { + originalUrl: primaryUrl, + lowUrl: primaryUrl, + mediumUrl: primaryUrl, + highUrl: primaryUrl, + }; + } + + private async deleteSavedImageAsset(upload: SavedImageUpload): Promise { + const anchorUrl = + upload.variants.mediumUrl || + upload.variants.highUrl || + upload.variants.lowUrl || + upload.variants.originalUrl || + upload.primaryUrl; + + const hasManagedVariantGroup = [ + upload.variants.originalUrl, + upload.variants.lowUrl, + upload.variants.mediumUrl, + upload.variants.highUrl, + ].some((url) => !!url && url !== upload.primaryUrl); + + if (anchorUrl && hasManagedVariantGroup) { + await this.storageService.deleteContainingDirectory(anchorUrl); + return; + } + + await this.deleteManagedPostMedia(upload.primaryUrl); + } + + private async deleteThumbnailAsset( + thumbnailUrl: string, + thumbnailVariants: PostMediaVariantSet | null, + ): Promise { + if (!thumbnailUrl) { + return; + } + + const anchorUrl = + thumbnailVariants?.mediumUrl || + thumbnailVariants?.highUrl || + thumbnailVariants?.lowUrl || + thumbnailVariants?.originalUrl || + ''; + + const hasManagedVariantGroup = !!thumbnailVariants && + [ + thumbnailVariants.originalUrl, + thumbnailVariants.lowUrl, + thumbnailVariants.mediumUrl, + thumbnailVariants.highUrl, + ].some((url) => !!url && url !== thumbnailUrl); + + if (anchorUrl && hasManagedVariantGroup) { + await this.storageService.deleteContainingDirectory(anchorUrl); + return; + } + + await this.deleteManagedPostMedia(thumbnailUrl); + } + private async deleteManagedPostMedia(fileUrl: string): Promise { await this.storageService.deleteFile(fileUrl); } diff --git a/src/modules/posts/schemas/post.schema.ts b/src/modules/posts/schemas/post.schema.ts index 7428443..2f179fa 100644 --- a/src/modules/posts/schemas/post.schema.ts +++ b/src/modules/posts/schemas/post.schema.ts @@ -1,16 +1,32 @@ -import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Prop, Schema, SchemaFactory, raw } from '@nestjs/mongoose'; import { HydratedDocument, Types } from 'mongoose'; import { ModerationStatus } from '../../../common/enums/moderation-status.enum'; import { PostType } from '../../../common/enums/post-type.enum'; import { PostVisibility } from '../../../common/enums/post-visibility.enum'; import { resolveManagedFileUrl, + resolveManagedFileUrlRecord, + resolveManagedFileUrlRecords, resolveManagedFileUrls, } from '../../../common/utils/public-url.util'; import { User } from '../../users/schemas/user.schema'; export type PostDocument = HydratedDocument; +export type PostMediaVariantSet = { + originalUrl: string; + lowUrl: string; + mediumUrl: string; + highUrl: string; +}; + +const mediaVariantSetSchema = raw({ + originalUrl: { type: String, default: '' }, + lowUrl: { type: String, default: '' }, + mediumUrl: { type: String, default: '' }, + highUrl: { type: String, default: '' }, +}); + @Schema({ timestamps: true, versionKey: false }) export class Post { @Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true }) @@ -34,6 +50,9 @@ export class Post { @Prop({ default: '' }) thumbnailUrl!: string; + @Prop({ type: mediaVariantSetSchema, default: null }) + thumbnailVariants!: PostMediaVariantSet | null; + @Prop({ default: '', trim: true, maxlength: 80 }) style!: string; @@ -49,6 +68,9 @@ export class Post { @Prop({ type: [String], default: [] }) imageUrls!: string[]; + @Prop({ type: [mediaVariantSetSchema], default: [] }) + imageVariants!: PostMediaVariantSet[]; + @Prop({ type: [Types.ObjectId], ref: User.name, default: [], index: true }) taggedUserIds!: Types.ObjectId[]; @@ -136,10 +158,12 @@ PostSchema.index({ const transformManagedPostFiles = (_doc: unknown, ret: any) => { ret.imageUrls = resolveManagedFileUrls(ret.imageUrls); + ret.imageVariants = resolveManagedFileUrlRecords(ret.imageVariants); ret.videoUrl = resolveManagedFileUrl(ret.videoUrl); ret.hlsUrl = resolveManagedFileUrl(ret.hlsUrl); ret.audioUrl = resolveManagedFileUrl(ret.audioUrl); ret.thumbnailUrl = resolveManagedFileUrl(ret.thumbnailUrl); + ret.thumbnailVariants = resolveManagedFileUrlRecord(ret.thumbnailVariants); return ret; };