import FS from 'fs' ; import Path from 'path' ; import Util from 'util' ; import type Stream from 'stream' ; import ChildProcess from 'child_process' ; import Config from '../config' ; import GM from 'gm' ; import Rimraf from 'rimraf' ; import type { Database } from '../types' ; import type { Media , SegmentMetadata } from '@vimtur/common' ; import { ImportUtils , Quality } from './import-utils' ; export class Transcoder { private database : Database ; public constructor ( database ) { this . database = database ; } public async createVideoThumbnail ( media ) { if ( media . type !== 'video' ) { throw new Error ( 'Cannot create video thumbnail for non-video media' ) ; } if ( ! media . metadata ) { throw new Error ( `Can't create thumbnail for media without metadata` ) ; } await ImportUtils . mkdir ( Config . get ( ) . cachePath ) ; await ImportUtils . mkdir ( `${ Config . get ( ) . cachePath }/thumbnails` ) ; const path = this . getThumbnailPath ( media ) ; const args = [ '-y' , '-vf' , 'thumbnail,scale=200:-1' , '-frames:v' , '1' ] ; if ( ! media . metadata . length ) { throw new Error ( `Can't get thumbnail for video with no length` ) ; } const inputOptions = [ '-hwaccel' , 'auto' ] ; if ( media . metadata . length > 10 ) { const offset = Math . ceil ( media . metadata . length / 4 ) ; inputOptions . push ( '-ss' ) ; inputOptions . push ( `00:00:${ offset >= 60 ? 59 : offset . toFixed ( 2 ) }` ) ; } await ImportUtils . transcode ( { input : media . absolutePath , output : path , outputOptions : args , inputOptions , } ) ; } public async createVideoPreview ( media ) { if ( media . type !== 'video' ) { throw new Error ( 'Cannot create video thumbnail for non-video media' ) ; } if ( ! media . metadata || media . metadata . length === undefined ) { throw new Error ( `Can't create thumbnail for media without metadata` ) ; } await ImportUtils . mkdir ( Config . get ( ) . cachePath ) ; await ImportUtils . mkdir ( `${ Config . get ( ) . cachePath }/previews` ) ; const fps = Config . get ( ) . transcoder . videoPreviewFps ; const count = Math . max ( 1 , Math . floor ( media . metadata . length / fps ) ) ; const height = Config . get ( ) . transcoder . videoPreviewHeight ; const maxPerColumn = Config . get ( ) . transcoder . videoPreviewMaxHeight / height ; const columns = Math . ceil ( count / maxPerColumn ) ; const cellsPerColumn = Math . ceil ( count / columns ) ; console . log ( `Creating preview. Count (${ count }), Columns (${ columns }), Cells Per Column (${ cellsPerColumn }) - ${ media . path }` ) ; const args = [ '-y' , '-vf' , `fps=1/${ fps },scale=-1:${ height },tile=${ columns }x${ cellsPerColumn }` , '-frames:v' , '1' , ] ; const path = this . getPreviewPath ( media ) ; await ImportUtils . transcode ( { input : media . absolutePath , inputOptions : [ '-hwaccel' , 'auto' ] , output : path , outputOptions : args , } ) ; } public async optimisePng ( path ) { await Util . promisify ( ChildProcess . exec ) ( `pngquant --speed 8 --force ${ path } -o ${ path }.optimised` ) ; await Util . promisify ( ChildProcess . exec ) ( `mv ${ path }.optimised ${ path }` ) ; } public getThumbnailPath ( media ) { return `${ Config . get ( ) . cachePath }/thumbnails/${ media . hash }.png` ; } public getPreviewPath ( media ) { return `${ Config . get ( ) . cachePath }/previews/${ media . hash }.png` ; } public async createImageThumbnail ( media ) { if ( media . type !== 'gif' && media . type !== 'still' ) { throw new Error ( 'Cannot create image thumbnail for non-image media' ) ; } await ImportUtils . mkdir ( Config . get ( ) . cachePath ) ; await ImportUtils . mkdir ( `${ Config . get ( ) . cachePath }/thumbnails` ) ; const output = this . getThumbnailPath ( media ) ; const gm = GM . subClass ( { nativeAutoOrient : true , imageMagick : true } ) ( media . absolutePath + ( media . type === 'gif' ? '[0]' : '' ) ) . autoOrient ( ) . resize ( 200 , 200 ) ; await new Promise < void > ( ( resolve , reject ) => { gm . write ( output , ( err ) => { if ( err ) { reject ( err ) ; } else { resolve ( ) ; } } ) ; } ) ; } public async streamMedia ( media , start , end , stream , targetHeight , realtime ) { if ( ! media . metadata || ! media . metadata . length ) { throw new Error ( `Can't transcode media without metadata` ) ; } if ( end > media . metadata . length ) { throw new Error ( 'Requested end after end of video' ) ; } const inputOptions = [ '-hwaccel' , 'auto' , '-copyts' ] ; if ( start ) { inputOptions . push ( ... [ '-ss' , String ( start ) ] ) ; } inputOptions . push ( ... [ '-t' , String ( end - start ) ] ) ; const audioCodec = [ '-acodec' , 'aac' , '-ac' , '1' , '-strict' , '-2' ] ; const videoCodec = [ '-vcodec' ] ; if ( media . metadata . codec === 'h264' && ( ! targetHeight || targetHeight === media . metadata . height ) && Config . get ( ) . transcoder . maxCopyEnabled ) { videoCodec . push ( ... [ 'copy' ] ) ; } else { const qualityRaw = ImportUtils . calculateBandwidthFromQuality ( targetHeight || media . metadata . height , media , true ) ; const quality = `${ Math . ceil ( qualityRaw / 1000000 ) }M` ; const qualityBuffer = `${ Math . ceil ( ( qualityRaw * 2 ) / 1000000 ) }M` ; videoCodec . push ( ... [ 'libx264' , '-bsf:v' , 'h264_mp4toannexb' , '-tune' , 'film' , ... ( realtime ? [ '-quality' , 'realtime' , '-preset' , 'ultrafast' ] : [ '-quality' , 'good' , '-preset' , 'medium' ] ) , '-maxrate' , quality , '-bufsize' , qualityBuffer , ] ) ; } videoCodec . push ( ... [ '-max_muxing_queue_size' , '9999' ] ) ; const scale = targetHeight && targetHeight !== media . metadata . height ? [ '-vf' , `scale=-2:${ targetHeight }` ] : [ ] ; const args = [ '-y' , ... audioCodec , ... scale , ... videoCodec , '-f' , 'mpegts' , '-muxdelay' , '0' ] ; await ImportUtils . transcode ( { input : media . absolutePath , output : stream , outputOptions : args , inputOptions , important : realtime , } ) ; } public async transcodeMedia ( media ) { if ( ! media . metadata ) { throw new Error ( `Can't transcode media set without metadata` ) ; } const desiredCaches = ImportUtils . getMediaDesiredQualities ( media , Config . get ( ) . transcoder . cacheQualities ) ; const actualCaches = media . metadata . qualityCache ?? [ ] ; const missingQualities = [ ] ; for ( const quality of desiredCaches ) { if ( ! actualCaches . find ( ( el ) => quality . quality === el ) ) { missingQualities . push ( quality ) ; } } if ( missingQualities . length ) { console . log ( `${ media . hash }: ${ missingQualities . length } missing quality caches detected.` ) ; await ImportUtils . mkdir ( `${ Config . get ( ) . cachePath }/${ media . hash }` ) ; for ( const quality of missingQualities ) { await this . transcodeMediaToQuality ( media , quality ) ; } } const redundant = ImportUtils . getRedundanctCaches ( desiredCaches , actualCaches ) ; if ( redundant . length ) { console . log ( `${ media . hash }: ${ redundant . length } redundant caches detected.` ) ; for ( const quality of redundant ) { console . log ( `${ media . hash }: Removing quality ${ quality }p...` ) ; await Util . promisify ( Rimraf ) ( `${ Config . get ( ) . cachePath }/${ media . hash }/${ quality }p` ) ; media . metadata . qualityCache ! . splice ( media . metadata . qualityCache ! . indexOf ( quality ) , 1 ) ; } console . log ( `${ media . hash }: Saving quality list...` ) ; await this . database . saveMedia ( media . hash , { metadata : { qualityCache : media . metadata . qualityCache , } , } ) ; } } public async getStreamPlaylist ( media , quality ) { if ( ! media . metadata ) { throw new Error ( 'Cannot get playlist for media without metadata' ) ; } if ( media . metadata . qualityCache && media . metadata . qualityCache . includes ( quality ) ) { const indexPath = Path . resolve ( Config . get ( ) . cachePath , media . hash , `${ quality }p` , 'index.m3u8' ) ; if ( await ImportUtils . exists ( indexPath ) ) { const cached = await Util . promisify ( FS . readFile ) ( indexPath ) ; return cached . toString ( ) ; } } const segments = await this . getStreamSegments ( media ) ; return ImportUtils . generateStreamPlaylist ( media , segments ) ; } public async getStreamSegments ( media ) { const segments = media . metadata ?. segments ?? ( await ImportUtils . generateSegments ( media ) ) ; if ( Config . get ( ) . transcoder . enableCachingKeyframes && ! media . metadata ?. segments ) { await this . database . saveMedia ( media . hash , { metadata : { segments , } , } ) ; } return segments ; } private async transcodeMediaToQuality ( media , requestedQuality ) { if ( ! media . metadata ) { throw new Error ( `Can't transcode media without metadata` ) ; } const targetHeight = requestedQuality . quality ; console . log ( `${ media . hash }: ${ media . path } (source ${ media . metadata . height }p) - Transcoding to ${ targetHeight }p...` ) ; media . metadata . qualityCache = media . metadata . qualityCache ?? [ ] ; if ( media . metadata . qualityCache . includes ( targetHeight ) ) { console . log ( `${ media . hash }: Already cached at ${ targetHeight }p.` ) ; return ; } media . metadata . qualityCache . push ( targetHeight ) ; await ImportUtils . deleteFolder ( `${ Config . get ( ) . cachePath }/${ media . hash }/${ targetHeight }p` ) ; await ImportUtils . mkdir ( `${ Config . get ( ) . cachePath }/${ media . hash }/${ targetHeight }p` ) ; const segmentMetadata = await this . getStreamSegments ( media ) ; for ( const segment of segmentMetadata . standard ) { const filename = `${ Config . get ( ) . cachePath }/${ media . hash }/${ targetHeight }p/data.ts?start=${ segment . start }&end=${ segment . end }` ; await this . streamMedia ( media , segment . start , segment . end , FS . createWriteStream ( filename ) , requestedQuality . quality , false ) ; } console . log ( `Saving metadata for ${ media . absolutePath }` ) ; try { await this . database . saveMedia ( media . hash , { metadata : media . metadata , } ) ; } catch ( err ) { console . log ( 'Failed to save media metadata.' , err , media ) ; } } }