Add adaptive media variants for weak networks
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
هذا الالتزام موجود في:
@@ -58,8 +58,15 @@ S3_ENDPOINT=
|
|||||||
S3_ACCESS_KEY_ID=
|
S3_ACCESS_KEY_ID=
|
||||||
S3_SECRET_ACCESS_KEY=
|
S3_SECRET_ACCESS_KEY=
|
||||||
S3_FORCE_PATH_STYLE=false
|
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_ENABLED=false
|
||||||
VIDEO_PROCESSING_FFMPEG_PATH=ffmpeg
|
VIDEO_PROCESSING_FFMPEG_PATH=ffmpeg
|
||||||
|
VIDEO_PROCESSING_FFPROBE_PATH=ffprobe
|
||||||
VIDEO_PROCESSING_MAX_WIDTH=1280
|
VIDEO_PROCESSING_MAX_WIDTH=1280
|
||||||
VIDEO_PROCESSING_MAX_FPS=30
|
VIDEO_PROCESSING_MAX_FPS=30
|
||||||
VIDEO_PROCESSING_CRF=28
|
VIDEO_PROCESSING_CRF=28
|
||||||
|
|||||||
@@ -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
|
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
|
## 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`
|
- uploaded post/reel videos are converted to optimized `mp4`
|
||||||
- `+faststart` is applied so playback begins faster on mobile/web
|
- `+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`
|
- 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`
|
- 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`
|
- 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
|
If `ffmpeg` is not installed or video processing is disabled, uploads still work and the original
|
||||||
video file is stored as-is.
|
video file is stored as-is.
|
||||||
@@ -222,6 +224,7 @@ Recommended client behavior:
|
|||||||
|
|
||||||
- use `hlsUrl` first when present for adaptive/streaming playback
|
- use `hlsUrl` first when present for adaptive/streaming playback
|
||||||
- fall back to `videoUrl` for progressive `mp4` 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
|
## 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`
|
- 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`
|
- 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:
|
Recommended client behavior:
|
||||||
|
|
||||||
- for audio, stream `audioUrl` directly and allow byte-range playback/resume
|
- 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
|
## Marketplace split
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { resolveManagedFileUrl, resolveManagedFileUrls } from './public-url.util';
|
import {
|
||||||
|
resolveManagedFileUrl,
|
||||||
|
resolveManagedFileUrlRecord,
|
||||||
|
resolveManagedFileUrlRecords,
|
||||||
|
resolveManagedFileUrls,
|
||||||
|
} from './public-url.util';
|
||||||
|
|
||||||
describe('public url util', () => {
|
describe('public url util', () => {
|
||||||
const originalPublicBaseUrl = process.env.PUBLIC_BASE_URL;
|
const originalPublicBaseUrl = process.env.PUBLIC_BASE_URL;
|
||||||
@@ -26,4 +31,34 @@ describe('public url util', () => {
|
|||||||
'http://192.168.1.12:4000/uploads/b.png',
|
'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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,3 +25,21 @@ export const resolveManagedFileUrls = (fileUrls: unknown): unknown => {
|
|||||||
|
|
||||||
return fileUrls.map((fileUrl) => resolveManagedFileUrl(fileUrl));
|
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));
|
||||||
|
};
|
||||||
|
|||||||
@@ -94,9 +94,24 @@ export default () => ({
|
|||||||
(process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true',
|
(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: {
|
videoProcessing: {
|
||||||
enabled: (process.env.VIDEO_PROCESSING_ENABLED ?? 'false').toLowerCase() === 'true',
|
enabled: (process.env.VIDEO_PROCESSING_ENABLED ?? 'false').toLowerCase() === 'true',
|
||||||
ffmpegPath: process.env.VIDEO_PROCESSING_FFMPEG_PATH ?? 'ffmpeg',
|
ffmpegPath: process.env.VIDEO_PROCESSING_FFMPEG_PATH ?? 'ffmpeg',
|
||||||
|
ffprobePath: process.env.VIDEO_PROCESSING_FFPROBE_PATH ?? '',
|
||||||
maxWidth: Number(process.env.VIDEO_PROCESSING_MAX_WIDTH ?? 1280),
|
maxWidth: Number(process.env.VIDEO_PROCESSING_MAX_WIDTH ?? 1280),
|
||||||
maxFps: Number(process.env.VIDEO_PROCESSING_MAX_FPS ?? 30),
|
maxFps: Number(process.env.VIDEO_PROCESSING_MAX_FPS ?? 30),
|
||||||
crf: Number(process.env.VIDEO_PROCESSING_CRF ?? 28),
|
crf: Number(process.env.VIDEO_PROCESSING_CRF ?? 28),
|
||||||
|
|||||||
@@ -61,8 +61,15 @@ export const validationSchema = Joi.object({
|
|||||||
S3_ACCESS_KEY_ID: Joi.string().allow('').optional(),
|
S3_ACCESS_KEY_ID: Joi.string().allow('').optional(),
|
||||||
S3_SECRET_ACCESS_KEY: Joi.string().allow('').optional(),
|
S3_SECRET_ACCESS_KEY: Joi.string().allow('').optional(),
|
||||||
S3_FORCE_PATH_STYLE: Joi.boolean().truthy('true').falsy('false').default(false),
|
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_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||||
VIDEO_PROCESSING_FFMPEG_PATH: Joi.string().default('ffmpeg'),
|
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_WIDTH: Joi.number().min(320).max(3840).default(1280),
|
||||||
VIDEO_PROCESSING_MAX_FPS: Joi.number().min(12).max(60).default(30),
|
VIDEO_PROCESSING_MAX_FPS: Joi.number().min(12).max(60).default(30),
|
||||||
VIDEO_PROCESSING_CRF: Joi.number().min(18).max(35).default(28),
|
VIDEO_PROCESSING_CRF: Joi.number().min(18).max(35).default(28),
|
||||||
|
|||||||
245
src/infrastructure/storage/image-processing.service.ts
Normal file
245
src/infrastructure/storage/image-processing.service.ts
Normal file
@@ -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<ProcessedImageResult> {
|
||||||
|
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<boolean>('imageProcessing.enabled', { infer: true });
|
||||||
|
if (typeof explicit === 'boolean') {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.configService.get<boolean>('videoProcessing.enabled', { infer: true }) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFfmpegPath(): string {
|
||||||
|
return (
|
||||||
|
this.configService.get<string>('imageProcessing.ffmpegPath', { infer: true }) ??
|
||||||
|
this.configService.get<string>('videoProcessing.ffmpegPath', { infer: true }) ??
|
||||||
|
'ffmpeg'
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLowWidth(): number {
|
||||||
|
return this.configService.get<number>('imageProcessing.lowWidth', { infer: true }) ?? 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMediumWidth(): number {
|
||||||
|
return this.configService.get<number>('imageProcessing.mediumWidth', { infer: true }) ?? 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHighWidth(): number {
|
||||||
|
return this.configService.get<number>('imageProcessing.highWidth', { infer: true }) ?? 1280;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getQuality(): number {
|
||||||
|
return this.configService.get<number>('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<ProcessedImageVariantName, 'original'>,
|
||||||
|
width: number,
|
||||||
|
): Promise<ProcessedImageResult['variants'][number]> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
await new Promise<void>((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'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ImageProcessingService } from './image-processing.service';
|
||||||
import { ManagedStorageService } from './managed-storage.service';
|
import { ManagedStorageService } from './managed-storage.service';
|
||||||
import { VideoProcessingService } from './video-processing.service';
|
import { VideoProcessingService } from './video-processing.service';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [ManagedStorageService, VideoProcessingService],
|
providers: [ManagedStorageService, VideoProcessingService, ImageProcessingService],
|
||||||
exports: [ManagedStorageService, VideoProcessingService],
|
exports: [ManagedStorageService, VideoProcessingService, ImageProcessingService],
|
||||||
})
|
})
|
||||||
export class StorageModule {}
|
export class StorageModule {}
|
||||||
|
|||||||
@@ -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()
|
@Injectable()
|
||||||
export class VideoProcessingService {
|
export class VideoProcessingService {
|
||||||
private readonly logger = new Logger(VideoProcessingService.name);
|
private readonly logger = new Logger(VideoProcessingService.name);
|
||||||
@@ -180,6 +193,24 @@ export class VideoProcessingService {
|
|||||||
).trim();
|
).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getFfprobePath(): string {
|
||||||
|
const configured = (
|
||||||
|
this.configService.get<string>('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 {
|
private getMaxWidth(): number {
|
||||||
return this.configService.get<number>('videoProcessing.maxWidth', { infer: true }) ?? 1280;
|
return this.configService.get<number>('videoProcessing.maxWidth', { infer: true }) ?? 1280;
|
||||||
}
|
}
|
||||||
@@ -257,6 +288,112 @@ export class VideoProcessingService {
|
|||||||
): Promise<NonNullable<OptimizedVideoResult['generatedHls']>> {
|
): Promise<NonNullable<OptimizedVideoResult['generatedHls']>> {
|
||||||
await mkdir(hlsDir, { recursive: true });
|
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<NonNullable<OptimizedVideoResult['generatedHls']>> {
|
||||||
|
await mkdir(hlsDir, { recursive: true });
|
||||||
|
|
||||||
await this.runFfmpeg([
|
await this.runFfmpeg([
|
||||||
'-y',
|
'-y',
|
||||||
'-i',
|
'-i',
|
||||||
@@ -288,14 +425,7 @@ export class VideoProcessingService {
|
|||||||
join(hlsDir, 'playlist.m3u8'),
|
join(hlsDir, 'playlist.m3u8'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const fileNames = (await readdir(hlsDir)).sort((left, right) => left.localeCompare(right));
|
const files = await this.readFilesRecursively(hlsDir);
|
||||||
const files = await Promise.all(
|
|
||||||
fileNames.map(async (fileName) => ({
|
|
||||||
relativePath: fileName,
|
|
||||||
buffer: await readFile(join(hlsDir, fileName)),
|
|
||||||
contentType: this.resolveStreamingContentType(fileName),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
playlistRelativePath: 'playlist.m3u8',
|
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<ProbedVideoInfo> {
|
||||||
|
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<Array<{ relativePath: string; buffer: Buffer; contentType: string }>> {
|
||||||
|
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 {
|
private resolveStreamingContentType(fileName: string): string {
|
||||||
const extension = extname(fileName).toLowerCase();
|
const extension = extname(fileName).toLowerCase();
|
||||||
|
|
||||||
@@ -341,11 +584,16 @@ export class VideoProcessingService {
|
|||||||
return this.ffmpegAvailable;
|
return this.ffmpegAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runFfmpeg(args: string[]): Promise<void> {
|
private async runCommand(command: string, args: string[]): Promise<string> {
|
||||||
await new Promise<void>((resolve, reject) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
const child = spawn(this.getFfmpegPath(), args, { windowsHide: true });
|
const child = spawn(command, args, { windowsHide: true });
|
||||||
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
child.stderr.on('data', (chunk) => {
|
child.stderr.on('data', (chunk) => {
|
||||||
stderr += chunk.toString();
|
stderr += chunk.toString();
|
||||||
});
|
});
|
||||||
@@ -356,12 +604,16 @@ export class VideoProcessingService {
|
|||||||
|
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve();
|
resolve(stdout);
|
||||||
return;
|
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<void> {
|
||||||
|
await this.runCommand(this.getFfmpegPath(), args);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import {
|
|||||||
normalizeWaveformPeaks,
|
normalizeWaveformPeaks,
|
||||||
} from '../../common/utils/waveform.util';
|
} from '../../common/utils/waveform.util';
|
||||||
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
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 { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
||||||
import {
|
import {
|
||||||
UploadedVideoFile,
|
UploadedVideoFile,
|
||||||
@@ -26,7 +30,7 @@ import { CreatePostDto } from './dto/create-post.dto';
|
|||||||
import { PostQueryDto } from './dto/post-query.dto';
|
import { PostQueryDto } from './dto/post-query.dto';
|
||||||
import { ReelQueryDto } from './dto/reel-query.dto';
|
import { ReelQueryDto } from './dto/reel-query.dto';
|
||||||
import { UpdatePostDto } from './dto/update-post.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';
|
import { PostsRepository } from './posts.repository';
|
||||||
|
|
||||||
type PostMediaMetadataInput = Pick<
|
type PostMediaMetadataInput = Pick<
|
||||||
@@ -52,6 +56,12 @@ type SavedVideoUpload = {
|
|||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
hlsUrl: string;
|
hlsUrl: string;
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
|
thumbnailVariants: PostMediaVariantSet | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SavedImageUpload = {
|
||||||
|
primaryUrl: string;
|
||||||
|
variants: PostMediaVariantSet;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -62,6 +72,7 @@ export class PostsService {
|
|||||||
private readonly postsRepository: PostsRepository,
|
private readonly postsRepository: PostsRepository,
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
private readonly storageService: ManagedStorageService,
|
private readonly storageService: ManagedStorageService,
|
||||||
|
private readonly imageProcessingService: ImageProcessingService,
|
||||||
private readonly videoProcessingService: VideoProcessingService,
|
private readonly videoProcessingService: VideoProcessingService,
|
||||||
private readonly feedVersionService: FeedVersionService,
|
private readonly feedVersionService: FeedVersionService,
|
||||||
private readonly notificationsService: NotificationsService,
|
private readonly notificationsService: NotificationsService,
|
||||||
@@ -98,13 +109,17 @@ export class PostsService {
|
|||||||
throw new BadRequestException('Post can contain either images or audio, not both');
|
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 savedVideoUpload = videoFile ? await this.saveVideoUpload(videoFile) : null;
|
||||||
const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? '';
|
const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? '';
|
||||||
const uploadedHlsUrl = savedVideoUpload?.hlsUrl ?? '';
|
const uploadedHlsUrl = savedVideoUpload?.hlsUrl ?? '';
|
||||||
const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? '';
|
const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? '';
|
||||||
|
const uploadedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null;
|
||||||
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
|
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
|
||||||
const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls;
|
const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls;
|
||||||
|
const finalImageVariants = uploadedImageVariants.length ? uploadedImageVariants : [];
|
||||||
const finalVideoUrl = uploadedVideoUrl || dto.videoUrl || '';
|
const finalVideoUrl = uploadedVideoUrl || dto.videoUrl || '';
|
||||||
const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || '';
|
const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || '';
|
||||||
const finalContent = dto.content?.trim() ?? '';
|
const finalContent = dto.content?.trim() ?? '';
|
||||||
@@ -128,9 +143,11 @@ export class PostsService {
|
|||||||
post = await this.postsRepository.create(userId, {
|
post = await this.postsRepository.create(userId, {
|
||||||
content: finalContent,
|
content: finalContent,
|
||||||
imageUrls: finalImageUrls,
|
imageUrls: finalImageUrls,
|
||||||
|
imageVariants: finalImageVariants,
|
||||||
videoUrl: finalVideoUrl,
|
videoUrl: finalVideoUrl,
|
||||||
hlsUrl: uploadedHlsUrl,
|
hlsUrl: uploadedHlsUrl,
|
||||||
audioUrl: finalAudioUrl,
|
audioUrl: finalAudioUrl,
|
||||||
|
thumbnailVariants: uploadedThumbnailVariants,
|
||||||
taggedUserIds,
|
taggedUserIds,
|
||||||
mentionUsernames: mentionResolution.mentionUsernames,
|
mentionUsernames: mentionResolution.mentionUsernames,
|
||||||
location,
|
location,
|
||||||
@@ -143,10 +160,12 @@ export class PostsService {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
|
...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)),
|
||||||
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
||||||
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : 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(),
|
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
|
||||||
]);
|
]);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -199,13 +218,21 @@ export class PostsService {
|
|||||||
throw new BadRequestException('Post can contain either images or audio, not both');
|
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 savedVideoUpload = videoFile ? await this.saveVideoUpload(videoFile) : null;
|
||||||
const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? '';
|
const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? '';
|
||||||
const uploadedHlsUrl = savedVideoUpload?.hlsUrl ?? '';
|
const uploadedHlsUrl = savedVideoUpload?.hlsUrl ?? '';
|
||||||
const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? '';
|
const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? '';
|
||||||
|
const uploadedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null;
|
||||||
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
|
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
|
||||||
const hasImageUpdate = typeof dto.imageUrls !== 'undefined' || imageFiles.length > 0;
|
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 hasVideoUpdate = typeof dto.videoUrl !== 'undefined' || !!videoFile;
|
||||||
const hasAudioUpdate = typeof dto.audioUrl !== 'undefined' || !!audioFile;
|
const hasAudioUpdate = typeof dto.audioUrl !== 'undefined' || !!audioFile;
|
||||||
@@ -214,6 +241,11 @@ export class PostsService {
|
|||||||
? uploadedImageUrls
|
? uploadedImageUrls
|
||||||
: inputImageUrls
|
: inputImageUrls
|
||||||
: post.imageUrls ?? [];
|
: post.imageUrls ?? [];
|
||||||
|
const nextImageVariants = hasImageUpdate
|
||||||
|
? imageFiles.length
|
||||||
|
? uploadedImageVariants
|
||||||
|
: []
|
||||||
|
: existingImageVariants;
|
||||||
|
|
||||||
const nextVideoUrl = hasVideoUpdate
|
const nextVideoUrl = hasVideoUpdate
|
||||||
? videoFile
|
? videoFile
|
||||||
@@ -226,6 +258,11 @@ export class PostsService {
|
|||||||
? uploadedAudioUrl
|
? uploadedAudioUrl
|
||||||
: dto.audioUrl ?? ''
|
: dto.audioUrl ?? ''
|
||||||
: post.audioUrl ?? '';
|
: post.audioUrl ?? '';
|
||||||
|
const nextThumbnailVariants = videoFile
|
||||||
|
? uploadedThumbnailVariants
|
||||||
|
: typeof dto.thumbnailUrl === 'string'
|
||||||
|
? null
|
||||||
|
: existingThumbnailVariants;
|
||||||
const nextPostType = this.resolvePostType(nextImageUrls, nextVideoUrl, nextAudioUrl);
|
const nextPostType = this.resolvePostType(nextImageUrls, nextVideoUrl, nextAudioUrl);
|
||||||
const nextContent = typeof dto.content === 'string' ? dto.content.trim() : post.content ?? '';
|
const nextContent = typeof dto.content === 'string' ? dto.content.trim() : post.content ?? '';
|
||||||
const nextTaggedUserIds =
|
const nextTaggedUserIds =
|
||||||
@@ -272,7 +309,9 @@ export class PostsService {
|
|||||||
...dto,
|
...dto,
|
||||||
content: nextContent,
|
content: nextContent,
|
||||||
imageUrls: nextImageUrls,
|
imageUrls: nextImageUrls,
|
||||||
|
imageVariants: nextImageVariants,
|
||||||
hlsUrl: nextHlsUrl,
|
hlsUrl: nextHlsUrl,
|
||||||
|
thumbnailVariants: nextThumbnailVariants,
|
||||||
taggedUserIds: nextTaggedUserIds,
|
taggedUserIds: nextTaggedUserIds,
|
||||||
mentionUsernames: mentionResolution.mentionUsernames,
|
mentionUsernames: mentionResolution.mentionUsernames,
|
||||||
location: nextLocation,
|
location: nextLocation,
|
||||||
@@ -300,40 +339,51 @@ export class PostsService {
|
|||||||
payload.videoUrl = '';
|
payload.videoUrl = '';
|
||||||
payload.audioUrl = '';
|
payload.audioUrl = '';
|
||||||
payload.hlsUrl = '';
|
payload.hlsUrl = '';
|
||||||
|
payload.thumbnailVariants = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoFile) {
|
if (videoFile) {
|
||||||
payload.videoUrl = uploadedVideoUrl;
|
payload.videoUrl = uploadedVideoUrl;
|
||||||
payload.hlsUrl = uploadedHlsUrl;
|
payload.hlsUrl = uploadedHlsUrl;
|
||||||
|
payload.thumbnailVariants = uploadedThumbnailVariants;
|
||||||
payload.imageUrls = [];
|
payload.imageUrls = [];
|
||||||
|
payload.imageVariants = [];
|
||||||
payload.audioUrl = '';
|
payload.audioUrl = '';
|
||||||
}
|
}
|
||||||
if (audioFile) {
|
if (audioFile) {
|
||||||
payload.audioUrl = uploadedAudioUrl;
|
payload.audioUrl = uploadedAudioUrl;
|
||||||
payload.imageUrls = [];
|
payload.imageUrls = [];
|
||||||
|
payload.imageVariants = [];
|
||||||
payload.videoUrl = '';
|
payload.videoUrl = '';
|
||||||
payload.hlsUrl = '';
|
payload.hlsUrl = '';
|
||||||
}
|
}
|
||||||
|
if (nextPostType !== PostType.AUDIO && nextPostType !== PostType.VIDEO) {
|
||||||
|
payload.thumbnailVariants = null;
|
||||||
|
}
|
||||||
|
|
||||||
let updated: PostDocument | null;
|
let updated: PostDocument | null;
|
||||||
try {
|
try {
|
||||||
updated = await this.postsRepository.updateById(postId, payload as any);
|
updated = await this.postsRepository.updateById(postId, payload as any);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
|
...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)),
|
||||||
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
||||||
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : 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(),
|
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
|
||||||
]);
|
]);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
|
...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)),
|
||||||
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
||||||
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : 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(),
|
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
|
||||||
]);
|
]);
|
||||||
throw new NotFoundException('Post not found');
|
throw new NotFoundException('Post not found');
|
||||||
@@ -346,17 +396,18 @@ export class PostsService {
|
|||||||
await this.storageService.deleteContainingDirectory(post.hlsUrl ?? '');
|
await this.storageService.deleteContainingDirectory(post.hlsUrl ?? '');
|
||||||
}
|
}
|
||||||
if ((post.thumbnailUrl ?? '') !== (updated.thumbnailUrl ?? '')) {
|
if ((post.thumbnailUrl ?? '') !== (updated.thumbnailUrl ?? '')) {
|
||||||
await this.deleteManagedPostMedia(post.thumbnailUrl ?? '');
|
await this.deleteThumbnailAsset(post.thumbnailUrl ?? '', existingThumbnailVariants);
|
||||||
}
|
}
|
||||||
if (hasAudioUpdate && (post.audioUrl ?? '') !== (updated.audioUrl ?? '')) {
|
if (hasAudioUpdate && (post.audioUrl ?? '') !== (updated.audioUrl ?? '')) {
|
||||||
await this.deleteManagedPostMedia(post.audioUrl ?? '');
|
await this.deleteManagedPostMedia(post.audioUrl ?? '');
|
||||||
}
|
}
|
||||||
if (hasImageUpdate) {
|
if (hasImageUpdate) {
|
||||||
const nextImageSet = new Set(updated.imageUrls ?? []);
|
const nextImageSet = new Set(updated.imageUrls ?? []);
|
||||||
|
const existingImageAssets = this.buildSavedImageAssets(post.imageUrls ?? [], existingImageVariants);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
(post.imageUrls ?? [])
|
existingImageAssets
|
||||||
.filter((url) => !nextImageSet.has(url))
|
.filter((asset) => !nextImageSet.has(asset.primaryUrl))
|
||||||
.map((url) => this.deleteManagedPostMedia(url)),
|
.map((asset) => this.deleteSavedImageAsset(asset)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,11 +433,19 @@ export class PostsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.postsRepository.deleteById(postId, userId);
|
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([
|
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.deleteManagedPostMedia(post.videoUrl ?? ''),
|
||||||
this.storageService.deleteContainingDirectory(post.hlsUrl ?? ''),
|
this.storageService.deleteContainingDirectory(post.hlsUrl ?? ''),
|
||||||
this.deleteManagedPostMedia(post.audioUrl ?? ''),
|
this.deleteManagedPostMedia(post.audioUrl ?? ''),
|
||||||
|
this.deleteThumbnailAsset(post.thumbnailUrl ?? '', existingThumbnailVariants),
|
||||||
]);
|
]);
|
||||||
await this.usersRepository.incrementPostsCount(userId, -1);
|
await this.usersRepository.incrementPostsCount(userId, -1);
|
||||||
await this.feedVersionService.bumpGlobalVersion();
|
await this.feedVersionService.bumpGlobalVersion();
|
||||||
@@ -623,12 +682,20 @@ export class PostsService {
|
|||||||
throw new NotFoundException('Post not found');
|
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 this.postsRepository.deleteById(postId, superAdminIdentifier);
|
||||||
await Promise.all([
|
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.deleteManagedPostMedia(post.videoUrl ?? ''),
|
||||||
this.storageService.deleteContainingDirectory(post.hlsUrl ?? ''),
|
this.storageService.deleteContainingDirectory(post.hlsUrl ?? ''),
|
||||||
this.deleteManagedPostMedia(post.audioUrl ?? ''),
|
this.deleteManagedPostMedia(post.audioUrl ?? ''),
|
||||||
|
this.deleteThumbnailAsset(post.thumbnailUrl ?? '', existingThumbnailVariants),
|
||||||
]);
|
]);
|
||||||
const authorId = this.extractEntityId(post.authorId);
|
const authorId = this.extractEntityId(post.authorId);
|
||||||
if (authorId) {
|
if (authorId) {
|
||||||
@@ -938,6 +1005,7 @@ export class PostsService {
|
|||||||
let videoUrl = '';
|
let videoUrl = '';
|
||||||
let hlsUrl = '';
|
let hlsUrl = '';
|
||||||
let thumbnailUrl = '';
|
let thumbnailUrl = '';
|
||||||
|
let thumbnailVariants: PostMediaVariantSet | null = null;
|
||||||
const hlsFolderName = `stream-${new Types.ObjectId().toString()}`;
|
const hlsFolderName = `stream-${new Types.ObjectId().toString()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -958,21 +1026,22 @@ export class PostsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (optimized.generatedThumbnail) {
|
if (optimized.generatedThumbnail) {
|
||||||
thumbnailUrl = await this.storageService.saveFile({
|
const savedThumbnail = await this.saveResponsiveImageAsset('thumbnails', {
|
||||||
folderSegments: ['posts', 'thumbnails'],
|
|
||||||
extension: optimized.generatedThumbnail.extension,
|
|
||||||
buffer: optimized.generatedThumbnail.buffer,
|
buffer: optimized.generatedThumbnail.buffer,
|
||||||
contentType: optimized.generatedThumbnail.contentType,
|
size: optimized.generatedThumbnail.buffer.length,
|
||||||
fileNamePrefix: 'thumbnail',
|
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) {
|
} catch (error) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
videoUrl ? this.deleteManagedPostMedia(videoUrl) : Promise.resolve(),
|
videoUrl ? this.deleteManagedPostMedia(videoUrl) : Promise.resolve(),
|
||||||
hlsUrl ? this.storageService.deleteContainingDirectory(hlsUrl) : Promise.resolve(),
|
hlsUrl ? this.storageService.deleteContainingDirectory(hlsUrl) : Promise.resolve(),
|
||||||
thumbnailUrl ? this.deleteManagedPostMedia(thumbnailUrl) : Promise.resolve(),
|
thumbnailUrl ? this.deleteThumbnailAsset(thumbnailUrl, thumbnailVariants) : Promise.resolve(),
|
||||||
]);
|
]);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -1097,7 +1166,7 @@ export class PostsService {
|
|||||||
|
|
||||||
private async saveImageFiles(
|
private async saveImageFiles(
|
||||||
files: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>,
|
files: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>,
|
||||||
): Promise<string[]> {
|
): Promise<SavedImageUpload[]> {
|
||||||
if (!files.length) {
|
if (!files.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -1105,18 +1174,150 @@ export class PostsService {
|
|||||||
throw new BadRequestException('Post can contain up to 10 images');
|
throw new BadRequestException('Post can contain up to 10 images');
|
||||||
}
|
}
|
||||||
|
|
||||||
const urls: string[] = [];
|
const uploads: SavedImageUpload[] = [];
|
||||||
try {
|
try {
|
||||||
for (const file of files) {
|
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) {
|
} catch (error) {
|
||||||
await Promise.all(urls.map((url) => this.deleteManagedPostMedia(url)));
|
await Promise.all(uploads.map((upload) => this.deleteSavedImageAsset(upload)));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async saveResponsiveImageAsset(
|
||||||
|
folder: 'images' | 'thumbnails',
|
||||||
|
file: UploadedImageFile,
|
||||||
|
): Promise<SavedImageUpload> {
|
||||||
|
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<ReturnType<ImageProcessingService['processForResponsiveDelivery']>>,
|
||||||
|
savedFiles: Record<string, string>,
|
||||||
|
): 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
private async deleteManagedPostMedia(fileUrl: string): Promise<void> {
|
||||||
await this.storageService.deleteFile(fileUrl);
|
await this.storageService.deleteFile(fileUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { HydratedDocument, Types } from 'mongoose';
|
||||||
import { ModerationStatus } from '../../../common/enums/moderation-status.enum';
|
import { ModerationStatus } from '../../../common/enums/moderation-status.enum';
|
||||||
import { PostType } from '../../../common/enums/post-type.enum';
|
import { PostType } from '../../../common/enums/post-type.enum';
|
||||||
import { PostVisibility } from '../../../common/enums/post-visibility.enum';
|
import { PostVisibility } from '../../../common/enums/post-visibility.enum';
|
||||||
import {
|
import {
|
||||||
resolveManagedFileUrl,
|
resolveManagedFileUrl,
|
||||||
|
resolveManagedFileUrlRecord,
|
||||||
|
resolveManagedFileUrlRecords,
|
||||||
resolveManagedFileUrls,
|
resolveManagedFileUrls,
|
||||||
} from '../../../common/utils/public-url.util';
|
} from '../../../common/utils/public-url.util';
|
||||||
import { User } from '../../users/schemas/user.schema';
|
import { User } from '../../users/schemas/user.schema';
|
||||||
|
|
||||||
export type PostDocument = HydratedDocument<Post>;
|
export type PostDocument = HydratedDocument<Post>;
|
||||||
|
|
||||||
|
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 })
|
@Schema({ timestamps: true, versionKey: false })
|
||||||
export class Post {
|
export class Post {
|
||||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||||
@@ -34,6 +50,9 @@ export class Post {
|
|||||||
@Prop({ default: '' })
|
@Prop({ default: '' })
|
||||||
thumbnailUrl!: string;
|
thumbnailUrl!: string;
|
||||||
|
|
||||||
|
@Prop({ type: mediaVariantSetSchema, default: null })
|
||||||
|
thumbnailVariants!: PostMediaVariantSet | null;
|
||||||
|
|
||||||
@Prop({ default: '', trim: true, maxlength: 80 })
|
@Prop({ default: '', trim: true, maxlength: 80 })
|
||||||
style!: string;
|
style!: string;
|
||||||
|
|
||||||
@@ -49,6 +68,9 @@ export class Post {
|
|||||||
@Prop({ type: [String], default: [] })
|
@Prop({ type: [String], default: [] })
|
||||||
imageUrls!: string[];
|
imageUrls!: string[];
|
||||||
|
|
||||||
|
@Prop({ type: [mediaVariantSetSchema], default: [] })
|
||||||
|
imageVariants!: PostMediaVariantSet[];
|
||||||
|
|
||||||
@Prop({ type: [Types.ObjectId], ref: User.name, default: [], index: true })
|
@Prop({ type: [Types.ObjectId], ref: User.name, default: [], index: true })
|
||||||
taggedUserIds!: Types.ObjectId[];
|
taggedUserIds!: Types.ObjectId[];
|
||||||
|
|
||||||
@@ -136,10 +158,12 @@ PostSchema.index({
|
|||||||
|
|
||||||
const transformManagedPostFiles = (_doc: unknown, ret: any) => {
|
const transformManagedPostFiles = (_doc: unknown, ret: any) => {
|
||||||
ret.imageUrls = resolveManagedFileUrls(ret.imageUrls);
|
ret.imageUrls = resolveManagedFileUrls(ret.imageUrls);
|
||||||
|
ret.imageVariants = resolveManagedFileUrlRecords(ret.imageVariants);
|
||||||
ret.videoUrl = resolveManagedFileUrl(ret.videoUrl);
|
ret.videoUrl = resolveManagedFileUrl(ret.videoUrl);
|
||||||
ret.hlsUrl = resolveManagedFileUrl(ret.hlsUrl);
|
ret.hlsUrl = resolveManagedFileUrl(ret.hlsUrl);
|
||||||
ret.audioUrl = resolveManagedFileUrl(ret.audioUrl);
|
ret.audioUrl = resolveManagedFileUrl(ret.audioUrl);
|
||||||
ret.thumbnailUrl = resolveManagedFileUrl(ret.thumbnailUrl);
|
ret.thumbnailUrl = resolveManagedFileUrl(ret.thumbnailUrl);
|
||||||
|
ret.thumbnailVariants = resolveManagedFileUrlRecord(ret.thumbnailVariants);
|
||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم