2023-05-22 20:07:43 +02:00
import { CropOptions , IMediaRepository , ResizeOptions , TranscodeOptions , VideoInfo } from '@app/domain' ;
2023-09-03 08:21:51 +02:00
import { Colorspace } from '@app/infra/entities' ;
2023-07-09 04:43:11 +02:00
import { Logger } from '@nestjs/common' ;
2023-04-04 16:48:02 +02:00
import ffmpeg , { FfprobeData } from 'fluent-ffmpeg' ;
2023-06-16 21:54:17 +02:00
import fs from 'fs/promises' ;
2023-02-25 15:12:03 +01:00
import sharp from 'sharp' ;
2023-09-03 08:21:51 +02:00
import { Writable } from 'stream' ;
2023-04-04 16:48:02 +02:00
import { promisify } from 'util' ;
const probe = promisify < string , FfprobeData > ( ffmpeg . ffprobe ) ;
2023-08-02 03:56:10 +02:00
sharp . concurrency ( 0 ) ;
2023-02-25 15:12:03 +01:00
export class MediaRepository implements IMediaRepository {
2023-07-09 04:43:11 +02:00
private logger = new Logger ( MediaRepository . name ) ;
2023-09-03 08:21:51 +02:00
crop ( input : string | Buffer , options : CropOptions ) : Promise < Buffer > {
2023-08-08 16:39:51 +02:00
return sharp ( input , { failOn : 'none' } )
2023-09-05 01:24:55 +02:00
. pipelineColorspace ( 'rgb16' )
2023-05-17 19:07:17 +02:00
. extract ( {
left : options.left ,
top : options.top ,
width : options.width ,
height : options.height ,
} )
. toBuffer ( ) ;
}
async resize ( input : string | Buffer , output : string , options : ResizeOptions ) : Promise < void > {
2023-09-03 08:21:51 +02:00
const chromaSubsampling = options . quality >= 80 ? '4:4:4' : '4:2:0' ; // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
2023-09-05 01:24:55 +02:00
await sharp ( input , { failOn : 'none' } )
2023-09-26 01:18:47 +02:00
. pipelineColorspace ( options . colorspace === Colorspace . SRGB ? 'srgb' : 'rgb16' )
2023-08-07 22:35:25 +02:00
. resize ( options . size , options . size , { fit : 'outside' , withoutEnlargement : true } )
. rotate ( )
2023-09-26 01:18:47 +02:00
. withMetadata ( { icc : options.colorspace } )
2023-09-03 08:21:51 +02:00
. toFormat ( options . format , { quality : options.quality , chromaSubsampling } )
2023-08-07 22:35:25 +02:00
. toFile ( output ) ;
2023-02-25 15:12:03 +01:00
}
2023-04-04 16:48:02 +02:00
async probe ( input : string ) : Promise < VideoInfo > {
const results = await probe ( input ) ;
return {
2023-04-06 05:32:59 +02:00
format : {
formatName : results.format.format_name ,
formatLongName : results.format.format_long_name ,
duration : results.format.duration || 0 ,
} ,
videoStreams : results.streams
. filter ( ( stream ) = > stream . codec_type === 'video' )
. map ( ( stream ) = > ( {
2023-08-29 11:01:42 +02:00
index : stream.index ,
2023-04-06 05:32:59 +02:00
height : stream.height || 0 ,
width : stream.width || 0 ,
2023-08-02 03:56:10 +02:00
codecName : stream.codec_name === 'h265' ? 'hevc' : stream . codec_name ,
2023-04-06 05:32:59 +02:00
codecType : stream.codec_type ,
frameCount : Number.parseInt ( stream . nb_frames ? ? '0' ) ,
rotation : Number.parseInt ( ` ${ stream . rotation ? ? 0 } ` ) ,
2023-08-07 22:35:25 +02:00
isHDR : stream.color_transfer === 'smpte2084' || stream . color_transfer === 'arib-std-b67' ,
2023-04-06 05:32:59 +02:00
} ) ) ,
audioStreams : results.streams
. filter ( ( stream ) = > stream . codec_type === 'audio' )
. map ( ( stream ) = > ( {
2023-08-29 11:01:42 +02:00
index : stream.index ,
2023-04-06 05:32:59 +02:00
codecType : stream.codec_type ,
codecName : stream.codec_name ,
2023-08-29 11:01:42 +02:00
frameCount : Number.parseInt ( stream . nb_frames ? ? '0' ) ,
2023-04-06 05:32:59 +02:00
} ) ) ,
2023-04-04 16:48:02 +02:00
} ;
}
2023-09-03 08:21:51 +02:00
transcode ( input : string , output : string | Writable , options : TranscodeOptions ) : Promise < void > {
2023-05-22 20:07:43 +02:00
if ( ! options . twoPass ) {
return new Promise ( ( resolve , reject ) = > {
ffmpeg ( input , { niceness : 10 } )
2023-08-02 03:56:10 +02:00
. inputOptions ( options . inputOptions )
2023-05-22 20:07:43 +02:00
. outputOptions ( options . outputOptions )
. output ( output )
2023-07-09 04:43:11 +02:00
. on ( 'error' , ( err , stdout , stderr ) = > {
this . logger . error ( stderr ) ;
reject ( err ) ;
} )
2023-05-22 20:07:43 +02:00
. on ( 'end' , resolve )
. run ( ) ;
} ) ;
}
2023-09-03 08:21:51 +02:00
if ( typeof output !== 'string' ) {
throw new Error ( 'Two-pass transcoding does not support writing to a stream' ) ;
}
2023-05-22 20:07:43 +02:00
// two-pass allows for precise control of bitrate at the cost of running twice
// recommended for vp9 for better quality and compression
2023-04-04 16:48:02 +02:00
return new Promise ( ( resolve , reject ) = > {
2023-04-06 05:32:59 +02:00
ffmpeg ( input , { niceness : 10 } )
2023-08-02 03:56:10 +02:00
. inputOptions ( options . inputOptions )
2023-05-22 20:07:43 +02:00
. outputOptions ( options . outputOptions )
. addOptions ( '-pass' , '1' )
. addOptions ( '-passlogfile' , output )
. addOptions ( '-f null' )
. output ( '/dev/null' ) // first pass output is not saved as only the .log file is needed
2023-07-09 04:43:11 +02:00
. on ( 'error' , ( err , stdout , stderr ) = > {
this . logger . error ( stderr ) ;
reject ( err ) ;
} )
2023-05-22 20:07:43 +02:00
. on ( 'end' , ( ) = > {
// second pass
ffmpeg ( input , { niceness : 10 } )
2023-08-02 03:56:10 +02:00
. inputOptions ( options . inputOptions )
2023-05-22 20:07:43 +02:00
. outputOptions ( options . outputOptions )
. addOptions ( '-pass' , '2' )
. addOptions ( '-passlogfile' , output )
. output ( output )
2023-07-09 04:43:11 +02:00
. on ( 'error' , ( err , stdout , stderr ) = > {
this . logger . error ( stderr ) ;
reject ( err ) ;
} )
2023-05-22 20:07:43 +02:00
. on ( 'end' , ( ) = > fs . unlink ( ` ${ output } -0.log ` ) )
2023-05-31 03:52:57 +02:00
. on ( 'end' , ( ) = > fs . rm ( ` ${ output } -0.log.mbtree ` , { force : true } ) )
2023-05-22 20:07:43 +02:00
. on ( 'end' , resolve )
. run ( ) ;
} )
2023-04-04 16:48:02 +02:00
. run ( ) ;
} ) ;
}
2023-06-18 05:22:31 +02:00
async generateThumbhash ( imagePath : string ) : Promise < Buffer > {
const maxSize = 100 ;
const { data , info } = await sharp ( imagePath )
. resize ( maxSize , maxSize , { fit : 'inside' , withoutEnlargement : true } )
. raw ( )
. ensureAlpha ( )
. toBuffer ( { resolveWithObject : true } ) ;
const thumbhash = await import ( 'thumbhash' ) ;
return Buffer . from ( thumbhash . rgbaToThumbHash ( info . width , info . height , data ) ) ;
}
2023-02-25 15:12:03 +01:00
}