2023-04-09 03:35:54 +02:00
import { AssetEntity , AssetType , TranscodePreset } from '@app/infra/entities' ;
2023-02-25 15:12:03 +01:00
import { Inject , Injectable , Logger } from '@nestjs/common' ;
import { join } from 'path' ;
2023-05-26 21:43:24 +02:00
import { IAssetRepository , WithoutProperty } from '../asset' ;
2023-05-22 20:05:06 +02:00
import { usePagination } from '../domain.util' ;
2023-05-26 21:43:24 +02:00
import { IBaseJob , IEntityJob , IJobRepository , JobName , JOBS_ASSET_PAGINATION_SIZE } from '../job' ;
2023-03-25 15:50:57 +01:00
import { IStorageRepository , StorageCore , StorageFolder } from '../storage' ;
2023-04-04 16:48:02 +02:00
import { ISystemConfigRepository , SystemConfigFFmpegDto } from '../system-config' ;
import { SystemConfigCore } from '../system-config/system-config.core' ;
2023-05-17 19:07:17 +02:00
import { JPEG_THUMBNAIL_SIZE , WEBP_THUMBNAIL_SIZE } from './media.constant' ;
2023-04-06 05:32:59 +02:00
import { AudioStreamInfo , IMediaRepository , VideoStreamInfo } from './media.repository' ;
2023-02-25 15:12:03 +01:00
@Injectable ( )
export class MediaService {
private logger = new Logger ( MediaService . name ) ;
2023-03-25 15:50:57 +01:00
private storageCore = new StorageCore ( ) ;
2023-04-04 16:48:02 +02:00
private configCore : SystemConfigCore ;
2023-02-25 15:12:03 +01:00
constructor (
@Inject ( IAssetRepository ) private assetRepository : IAssetRepository ,
@Inject ( IJobRepository ) private jobRepository : IJobRepository ,
@Inject ( IMediaRepository ) private mediaRepository : IMediaRepository ,
@Inject ( IStorageRepository ) private storageRepository : IStorageRepository ,
2023-04-04 16:48:02 +02:00
@Inject ( ISystemConfigRepository ) systemConfig : ISystemConfigRepository ,
) {
this . configCore = new SystemConfigCore ( systemConfig ) ;
}
2023-02-25 15:12:03 +01:00
2023-05-26 21:43:24 +02:00
async handleQueueGenerateThumbnails ( job : IBaseJob ) {
const { force } = job ;
2023-03-20 16:55:28 +01:00
2023-05-26 21:43:24 +02:00
const assetPagination = usePagination ( JOBS_ASSET_PAGINATION_SIZE , ( pagination ) = > {
return force
? this . assetRepository . getAll ( pagination )
: this . assetRepository . getWithout ( pagination , WithoutProperty . THUMBNAIL ) ;
} ) ;
2023-03-20 16:55:28 +01:00
2023-05-26 21:43:24 +02:00
for await ( const assets of assetPagination ) {
for ( const asset of assets ) {
2023-06-18 05:22:31 +02:00
if ( ! asset . resizePath || force ) {
await this . jobRepository . queue ( { name : JobName.GENERATE_JPEG_THUMBNAIL , data : { id : asset.id } } ) ;
continue ;
}
if ( ! asset . webpPath ) {
await this . jobRepository . queue ( { name : JobName.GENERATE_WEBP_THUMBNAIL , data : { id : asset.id } } ) ;
}
if ( ! asset . thumbhash ) {
await this . jobRepository . queue ( { name : JobName.GENERATE_THUMBHASH_THUMBNAIL , data : { id : asset.id } } ) ;
}
2023-03-20 16:55:28 +01:00
}
}
2023-05-26 21:43:24 +02:00
return true ;
}
2023-04-12 03:28:25 +02:00
2023-05-26 21:43:24 +02:00
async handleGenerateJpegThumbnail ( { id } : IEntityJob ) {
const [ asset ] = await this . assetRepository . getByIds ( [ id ] ) ;
2023-04-12 03:28:25 +02:00
if ( ! asset ) {
2023-05-26 21:43:24 +02:00
return false ;
2023-04-12 03:28:25 +02:00
}
2023-02-25 15:12:03 +01:00
2023-05-26 21:43:24 +02:00
const resizePath = this . storageCore . getFolderLocation ( StorageFolder . THUMBNAILS , asset . ownerId ) ;
this . storageRepository . mkdirSync ( resizePath ) ;
const jpegThumbnailPath = join ( resizePath , ` ${ asset . id } .jpeg ` ) ;
2023-06-15 05:42:35 +02:00
switch ( asset . type ) {
case AssetType . IMAGE :
2023-05-26 21:43:24 +02:00
await this . mediaRepository . resize ( asset . originalPath , jpegThumbnailPath , {
size : JPEG_THUMBNAIL_SIZE ,
format : 'jpeg' ,
} ) ;
2023-06-15 05:42:35 +02:00
break ;
case AssetType . VIDEO :
this . logger . log ( 'Generating video thumbnail' ) ;
await this . mediaRepository . extractVideoThumbnail ( asset . originalPath , jpegThumbnailPath , JPEG_THUMBNAIL_SIZE ) ;
this . logger . log ( ` Successfully generated video thumbnail ${ asset . id } ` ) ;
break ;
2023-05-26 21:43:24 +02:00
}
2023-02-25 15:12:03 +01:00
2023-05-26 21:43:24 +02:00
await this . assetRepository . save ( { id : asset.id , resizePath : jpegThumbnailPath } ) ;
2023-02-25 15:12:03 +01:00
2023-05-26 21:43:24 +02:00
return true ;
2023-02-25 15:12:03 +01:00
}
2023-05-26 21:43:24 +02:00
async handleGenerateWepbThumbnail ( { id } : IEntityJob ) {
const [ asset ] = await this . assetRepository . getByIds ( [ id ] ) ;
if ( ! asset || ! asset . resizePath ) {
return false ;
2023-02-25 15:12:03 +01:00
}
const webpPath = asset . resizePath . replace ( 'jpeg' , 'webp' ) ;
2023-05-26 21:43:24 +02:00
await this . mediaRepository . resize ( asset . resizePath , webpPath , { size : WEBP_THUMBNAIL_SIZE , format : 'webp' } ) ;
await this . assetRepository . save ( { id : asset.id , webpPath : webpPath } ) ;
return true ;
2023-02-25 15:12:03 +01:00
}
2023-04-04 16:48:02 +02:00
2023-06-18 05:22:31 +02:00
async handleGenerateThumbhashThumbnail ( { id } : IEntityJob ) : Promise < boolean > {
const [ asset ] = await this . assetRepository . getByIds ( [ id ] ) ;
if ( ! asset ? . resizePath ) {
return false ;
}
const thumbhash = await this . mediaRepository . generateThumbhash ( asset . resizePath ) ;
await this . assetRepository . save ( { id : asset.id , thumbhash } ) ;
return true ;
}
2023-04-04 16:48:02 +02:00
async handleQueueVideoConversion ( job : IBaseJob ) {
const { force } = job ;
2023-05-26 21:43:24 +02:00
const assetPagination = usePagination ( JOBS_ASSET_PAGINATION_SIZE , ( pagination ) = > {
return force
? this . assetRepository . getAll ( pagination , { type : AssetType . VIDEO } )
: this . assetRepository . getWithout ( pagination , WithoutProperty . ENCODED_VIDEO ) ;
} ) ;
for await ( const assets of assetPagination ) {
for ( const asset of assets ) {
await this . jobRepository . queue ( { name : JobName.VIDEO_CONVERSION , data : { id : asset.id } } ) ;
2023-04-04 16:48:02 +02:00
}
}
2023-05-26 21:43:24 +02:00
return true ;
}
2023-04-11 15:56:52 +02:00
2023-05-26 21:43:24 +02:00
async handleVideoConversion ( { id } : IEntityJob ) {
const [ asset ] = await this . assetRepository . getByIds ( [ id ] ) ;
2023-04-11 15:56:52 +02:00
if ( ! asset ) {
2023-05-26 21:43:24 +02:00
return false ;
2023-04-11 15:56:52 +02:00
}
2023-04-04 16:48:02 +02:00
2023-05-26 21:43:24 +02:00
const input = asset . originalPath ;
const outputFolder = this . storageCore . getFolderLocation ( StorageFolder . ENCODED_VIDEO , asset . ownerId ) ;
const output = join ( outputFolder , ` ${ asset . id } .mp4 ` ) ;
this . storageRepository . mkdirSync ( outputFolder ) ;
const { videoStreams , audioStreams , format } = await this . mediaRepository . probe ( input ) ;
const mainVideoStream = this . getMainVideoStream ( videoStreams ) ;
const mainAudioStream = this . getMainAudioStream ( audioStreams ) ;
const containerExtension = format . formatName ;
if ( ! mainVideoStream || ! mainAudioStream || ! containerExtension ) {
return false ;
}
2023-04-04 16:48:02 +02:00
2023-05-26 21:43:24 +02:00
const { ffmpeg : config } = await this . configCore . getConfig ( ) ;
2023-04-04 16:48:02 +02:00
2023-05-26 21:43:24 +02:00
const required = this . isTranscodeRequired ( asset , mainVideoStream , mainAudioStream , containerExtension , config ) ;
if ( ! required ) {
return false ;
}
2023-04-04 16:48:02 +02:00
2023-05-26 21:43:24 +02:00
const outputOptions = this . getFfmpegOptions ( mainVideoStream , config ) ;
const twoPass = this . eligibleForTwoPass ( config ) ;
2023-04-06 05:32:59 +02:00
2023-05-26 21:43:24 +02:00
this . logger . log ( ` Start encoding video ${ asset . id } ${ outputOptions } ` ) ;
await this . mediaRepository . transcode ( input , output , { outputOptions , twoPass } ) ;
2023-04-04 16:48:02 +02:00
2023-05-26 21:43:24 +02:00
this . logger . log ( ` Encoding success ${ asset . id } ` ) ;
2023-04-04 16:48:02 +02:00
2023-05-26 21:43:24 +02:00
await this . assetRepository . save ( { id : asset.id , encodedVideoPath : output } ) ;
return true ;
2023-04-04 16:48:02 +02:00
}
2023-04-06 05:32:59 +02:00
private getMainVideoStream ( streams : VideoStreamInfo [ ] ) : VideoStreamInfo | null {
return streams . sort ( ( stream1 , stream2 ) = > stream2 . frameCount - stream1 . frameCount ) [ 0 ] ;
}
private getMainAudioStream ( streams : AudioStreamInfo [ ] ) : AudioStreamInfo | null {
return streams [ 0 ] ;
2023-04-04 16:48:02 +02:00
}
2023-04-06 05:32:59 +02:00
private isTranscodeRequired (
2023-04-09 03:35:54 +02:00
asset : AssetEntity ,
2023-04-06 05:32:59 +02:00
videoStream : VideoStreamInfo ,
audioStream : AudioStreamInfo ,
containerExtension : string ,
ffmpegConfig : SystemConfigFFmpegDto ,
) : boolean {
if ( ! videoStream . height || ! videoStream . width ) {
2023-04-04 16:48:02 +02:00
this . logger . error ( 'Skipping transcode, height or width undefined for video stream' ) ;
return false ;
}
2023-04-06 05:32:59 +02:00
const isTargetVideoCodec = videoStream . codecName === ffmpegConfig . targetVideoCodec ;
const isTargetAudioCodec = audioStream . codecName === ffmpegConfig . targetAudioCodec ;
const isTargetContainer = [ 'mov,mp4,m4a,3gp,3g2,mj2' , 'mp4' , 'mov' ] . includes ( containerExtension ) ;
2023-04-09 03:35:54 +02:00
this . logger . verbose (
` ${ asset . id } : AudioCodecName ${ audioStream . codecName } , AudioStreamCodecType ${ audioStream . codecType } , containerExtension ${ containerExtension } ` ,
) ;
2023-04-06 05:32:59 +02:00
const allTargetsMatching = isTargetVideoCodec && isTargetAudioCodec && isTargetContainer ;
2023-06-10 06:15:12 +02:00
const scalingEnabled = ffmpegConfig . targetResolution !== 'original' ;
const targetRes = Number . parseInt ( ffmpegConfig . targetResolution ) ;
const isLargerThanTargetRes = scalingEnabled && Math . min ( videoStream . height , videoStream . width ) > targetRes ;
2023-04-04 16:48:02 +02:00
switch ( ffmpegConfig . transcode ) {
2023-04-06 05:32:59 +02:00
case TranscodePreset . DISABLED :
return false ;
2023-04-04 16:48:02 +02:00
case TranscodePreset . ALL :
return true ;
case TranscodePreset . REQUIRED :
2023-04-06 05:32:59 +02:00
return ! allTargetsMatching ;
2023-04-04 16:48:02 +02:00
case TranscodePreset . OPTIMAL :
2023-06-10 06:15:12 +02:00
return ! allTargetsMatching || isLargerThanTargetRes ;
2023-04-04 16:48:02 +02:00
default :
return false ;
}
}
private getFfmpegOptions ( stream : VideoStreamInfo , ffmpeg : SystemConfigFFmpegDto ) {
const options = [
` -vcodec ${ ffmpeg . targetVideoCodec } ` ,
` -acodec ${ ffmpeg . targetAudioCodec } ` ,
// Makes a second pass moving the moov atom to the beginning of
// the file for improved playback speed.
2023-07-01 03:48:40 +02:00
'-movflags faststart' ,
'-fps_mode passthrough' ,
2023-04-04 16:48:02 +02:00
] ;
2023-05-22 20:07:43 +02:00
// video dimensions
2023-04-04 16:48:02 +02:00
const videoIsRotated = Math . abs ( stream . rotation ) === 90 ;
2023-06-10 06:15:12 +02:00
const scalingEnabled = ffmpeg . targetResolution !== 'original' ;
2023-04-04 16:48:02 +02:00
const targetResolution = Number . parseInt ( ffmpeg . targetResolution ) ;
const isVideoVertical = stream . height > stream . width || videoIsRotated ;
const scaling = isVideoVertical ? ` ${ targetResolution } :-2 ` : ` -2: ${ targetResolution } ` ;
2023-06-10 06:15:12 +02:00
const shouldScale = scalingEnabled && Math . min ( stream . height , stream . width ) > targetResolution ;
2023-05-22 20:07:43 +02:00
// video codec
const isVP9 = ffmpeg . targetVideoCodec === 'vp9' ;
const isH264 = ffmpeg . targetVideoCodec === 'h264' ;
const isH265 = ffmpeg . targetVideoCodec === 'hevc' ;
// transcode efficiency
const limitThreads = ffmpeg . threads > 0 ;
const maxBitrateValue = Number . parseInt ( ffmpeg . maxBitrate ) || 0 ;
const constrainMaximumBitrate = maxBitrateValue > 0 ;
const bitrateUnit = ffmpeg . maxBitrate . trim ( ) . substring ( maxBitrateValue . toString ( ) . length ) ; // use inputted unit if provided
2023-04-04 16:48:02 +02:00
if ( shouldScale ) {
options . push ( ` -vf scale= ${ scaling } ` ) ;
}
2023-05-22 20:07:43 +02:00
if ( isH264 || isH265 ) {
options . push ( ` -preset ${ ffmpeg . preset } ` ) ;
}
if ( isVP9 ) {
// vp9 doesn't have presets, but does have a similar setting -cpu-used, from 0-5, 0 being the slowest
const presets = [ 'veryslow' , 'slower' , 'slow' , 'medium' , 'fast' , 'faster' , 'veryfast' , 'superfast' , 'ultrafast' ] ;
const speed = Math . min ( presets . indexOf ( ffmpeg . preset ) , 5 ) ; // values over 5 require realtime mode, which is its own can of worms since it overrides -crf and -threads
if ( speed >= 0 ) {
options . push ( ` -cpu-used ${ speed } ` ) ;
}
options . push ( '-row-mt 1' ) ; // better multithreading
}
if ( limitThreads ) {
options . push ( ` -threads ${ ffmpeg . threads } ` ) ;
// x264 and x265 handle threads differently than one might expect
// https://x265.readthedocs.io/en/latest/cli.html#cmdoption-pools
if ( isH264 || isH265 ) {
options . push ( ` - ${ isH265 ? 'x265' : 'x264' } -params "pools=none" ` ) ;
options . push ( ` - ${ isH265 ? 'x265' : 'x264' } -params "frame-threads= ${ ffmpeg . threads } " ` ) ;
}
}
// two-pass mode for x264/x265 uses bitrate ranges, so it requires a max bitrate from which to derive a target and min bitrate
if ( constrainMaximumBitrate && ffmpeg . twoPass ) {
const targetBitrateValue = Math . ceil ( maxBitrateValue / 1.45 ) ; // recommended by https://developers.google.com/media/vp9/settings/vod
const minBitrateValue = targetBitrateValue / 2 ;
options . push ( ` -b:v ${ targetBitrateValue } ${ bitrateUnit } ` ) ;
options . push ( ` -minrate ${ minBitrateValue } ${ bitrateUnit } ` ) ;
options . push ( ` -maxrate ${ maxBitrateValue } ${ bitrateUnit } ` ) ;
} else if ( constrainMaximumBitrate || isVP9 ) {
// for vp9, these flags work for both one-pass and two-pass
options . push ( ` -crf ${ ffmpeg . crf } ` ) ;
2023-07-01 03:48:05 +02:00
if ( isVP9 ) {
options . push ( ` -b:v ${ maxBitrateValue } ${ bitrateUnit } ` ) ;
} else {
options . push ( ` -maxrate ${ maxBitrateValue } ${ bitrateUnit } ` ) ;
// -bufsize is the peak possible bitrate at any moment, while -maxrate is the max rolling average bitrate
// needed for -maxrate to be enforced
options . push ( ` -bufsize ${ maxBitrateValue * 2 } ${ bitrateUnit } ` ) ;
}
2023-05-22 20:07:43 +02:00
} else {
options . push ( ` -crf ${ ffmpeg . crf } ` ) ;
}
2023-04-04 16:48:02 +02:00
return options ;
}
2023-05-22 20:07:43 +02:00
private eligibleForTwoPass ( ffmpeg : SystemConfigFFmpegDto ) {
if ( ! ffmpeg . twoPass ) {
return false ;
}
const isVP9 = ffmpeg . targetVideoCodec === 'vp9' ;
const maxBitrateValue = Number . parseInt ( ffmpeg . maxBitrate ) || 0 ;
const constrainMaximumBitrate = maxBitrateValue > 0 ;
return constrainMaximumBitrate || isVP9 ;
}
2023-02-25 15:12:03 +01:00
}