// npm import * as path from 'path'; import { v4 as uuid } from 'uuid'; import * as fs from 'fs'; import { sprintf } from 'sprintf-js'; import bluebird from 'bluebird'; import * as _ from 'lodash'; // @ownzones import { createFFmpegCommand } from '@ownzones/meh'; import { MxfFileReader, S3ByteProvider } from '@ownzones/imf'; // app import { IMXFProbe } from '@ownzones/imf/dist/lib/st0377/reader/mxf-file-reader'; import { helper } from './segment-builder-helper'; import { Profiler } from '../profiler'; import { IDecodeOptions, J2kDecoder } from '../j2k-decoder'; import { AcesDecoder } from '../aces-decoder'; import { Honeycomb } from '../tracing'; import { config, log } from '../../config'; import { IImageSegment } from '../playlist-builder'; import { Nullable, TrackedFFmpegCommand } from '../../utils/types'; import { ISegmentTranscoder, ISegmentTranscodeResult } from './segment-transcoder'; export enum FieldOrder { Progressive, FirstFieldDominance, SecondFieldDominance, } type ResolvedImageSegment = IImageSegment & { id: string, url: string, startTime: number }; // ToDo Use strategy pattern export class VideoSegment implements ISegmentTranscoder { private static isImageSequence(segment: ResolvedImageSegment): segment is ResolvedImageSegment & { firstFrameIndex: number } { const parts = segment.url.split('.'); if (!['tif', 'tiff', 'dpx', 'j2c', 'j2k'].includes(parts[parts.length - 1].toLowerCase())) { return false; } const matches = segment.url.match(/%0\d+d/g); return !!(matches && matches.length); } private readonly segment: IImageSegment & { id: string, url: string, startTime: number }; public constructor(segment: IImageSegment) { this.segment = segment as IImageSegment & { id: string, url: string, startTime: number }; } public async transcode(): Promise { let segmentPath: any; if (VideoSegment.isImageSequence(this.segment)) { log.debug('Image sequence transcode'); segmentPath = await this.imgseqTranscoder(); } else { const signedUrl = (this.segment.url.indexOf('s3') === 0) ? await helper.getSignedUrl(this.segment.url) : this.segment.url; const signedMxfIndexUrl = this.segment.mxfIndexUrl ? await helper.getSignedUrl(this.segment.mxfIndexUrl) : null; const mxfFileReader = this.segment.url.includes('.mxf') ? new MxfFileReader(S3ByteProvider.fromUrl(this.segment.url)) : null; segmentPath = mxfFileReader && await mxfFileReader.isSupportedFile() && ['j2k', 'jpeg2000', 'aces'].includes(this.segment.codec) ? await this.mxfTranscoder(mxfFileReader) : (['mpeg2video', 'hevc'].includes(this.segment.codec) && ['ts', 'mpg'].includes(this.segment.url.toLowerCase().split('.').pop() as string) ? await this.mpegtsTranscoder(signedUrl) : await this.movTranscoder(signedUrl, signedMxfIndexUrl)); } const content = fs.readFileSync(segmentPath); fs.unlink(segmentPath, () => null); return { segmentData: content }; } private async imgseqTranscoder(): Promise { if (!VideoSegment.isImageSequence(this.segment)) { throw new Error('The provided segment is not an image sequence'); } const frames = Math.round(this.segment.editRate * this.segment.duration); const startFrame = Math.round(this.segment.editRate * this.segment.startTime); const framesFileName = `${__dirname}/../../media/${uuid()}.txt`; const framesFile = fs.createWriteStream(framesFileName); const firstFrameIndex = this.segment.firstFrameIndex; await bluebird.map(_.range(startFrame, startFrame + frames), async (frameDelta) => { const file = sprintf(this.segment.url, firstFrameIndex + frameDelta); const signedUrl = await helper.getSignedUrl(file); framesFile.write(`file ${signedUrl}\n`); }); const editRateAsString = Math.abs(this.segment.editRate - Math.round(this.segment.editRate)) < 1e-6 ? Math.round(this.segment.editRate).toString(10) : sprintf('%.3f', this.segment.editRate); framesFile.close(); const { ffmpegCommand, ffmpegPromise } = createFFmpegCommand({ ffmpegTimeout: 600, }) as TrackedFFmpegCommand; ffmpegCommand.input(framesFileName) .inputOptions(`-r ${editRateAsString}`) .inputFormat('concat') .inputOptions('-safe 0') .inputOptions('-protocol_whitelist pipe,file,http,https,tcp,tls') .outputOptions([ '-muxdelay 0', '-copyts', '-c:v libx264', '-maxrate 5M', '-bufsize 3M', '-bf', '0', '-vf scale=-2:720', '-f', 'mpegts', '-profile:v', 'main', '-level', '4.0', '-pix_fmt', 'yuv420p', ]) .outputOptions(`-r ${editRateAsString}`); const segmentPath = `${__dirname}/../../media/${uuid()}.ts`; ffmpegCommand.save(segmentPath); await ffmpegPromise; fs.unlinkSync(framesFileName); return segmentPath; } private async yuvToTsSegment(j2kDecoder: J2kDecoder, sampleRate: number, fieldOrder: FieldOrder): Promise { const segmentPath = `${__dirname}/../../media/${uuid()}.ts`; const decodeOptions = j2kDecoder.decodeOptions as IDecodeOptions; const pixelDepth = decodeOptions.bitDepth === '8' ? '' : decodeOptions.bitDepth; let pixelFormat = `yuv${decodeOptions.chroma}p${pixelDepth}`; if (this.segment.pixFmt.includes('rgb')) { pixelFormat = `gbrp${this.segment.bitDepth as string}le`; } const fieldWidth = decodeOptions.width; const fieldHeight = decodeOptions.height; const frameWidth = fieldWidth; const frameHeight = fieldOrder === FieldOrder.Progressive ? fieldHeight : fieldHeight * 2; const { ffmpegCommand, ffmpegPromise } = createFFmpegCommand({ ffmpegTimeout: 40, }) as TrackedFFmpegCommand; ffmpegCommand.input(j2kDecoder.yuvPathWithSuffix as string); ffmpegCommand.inputOptions([ // input options '-s', `${fieldWidth}x${fieldHeight}`, '-f', 'rawvideo', '-pix_fmt', `${pixelFormat}`, '-r', `${sampleRate}`, ]); ffmpegCommand.outputOptions([ // output options '-muxdelay', '0', '-copyts', '-c:v libx264', '-maxrate 5M', '-bufsize 3M', '-bf', '0', '-f', 'mpegts', '-profile:v', 'main', '-level', '4.0', '-pix_fmt', 'yuv420p', ]); const filters = []; if (this.segment.pixFmt.includes('rgb')) { filters.push('colorchannelmixer=rr=0:rg=1:gg=0:gb=1:bb=0:br=1'); } if (fieldOrder === FieldOrder.FirstFieldDominance) { filters.push(`scale=${frameWidth}x${frameHeight},interlace=scan=tff:lowpass=off`); ffmpegCommand.outputOptions(['-x264opts', 'tff=1']); } if (fieldOrder === FieldOrder.SecondFieldDominance) { filters.push(`scale=${frameWidth}x${frameHeight},interlace=scan=bff:lowpass=off`); ffmpegCommand.outputOptions(['-x264opts', 'bff=1']); } if (filters.length) { ffmpegCommand .outputOptions(`-vf ${filters.join(',')}`); } ffmpegCommand.save(segmentPath); await ffmpegPromise; return segmentPath; } private async mxfTranscoder(mxfFileReader: MxfFileReader): Promise { const startFrameIndex = this.getStartFrameIndex(); const lastFrameIndex = this.getLastFrameIndex(startFrameIndex); if (['j2k', 'jpeg2000'].includes(this.segment.codec)) { const j2kDecoder = new J2kDecoder({ height: this.segment.height, width: this.segment.width, editRate: this.segment.editRate, pixFmt: this.segment.pixFmt, bitDepth: this.segment.bitDepth as string, startFrameIndex, lastFrameIndex, mxfFileReader, segmentId: this.segment.id, }); const spanKduExpand = Honeycomb.startSpan(path.basename(__filename), 'kduExpand'); await j2kDecoder.init(); j2kDecoder.startKduExpand(); Honeycomb.endSpan(spanKduExpand); // ToDo Resolve in lib. Every property is optional. const { frameLayout, fieldDominance, sampleRate } = await mxfFileReader.probe() as IMXFProbe & Record<'sampleRate', [number, number]>; const fieldOrder = frameLayout === 0 ? FieldOrder.Progressive : (fieldDominance === 1 ? FieldOrder.FirstFieldDominance : FieldOrder.SecondFieldDominance); const spanYuvToTsSegment = Honeycomb.startSpan(path.basename(__filename), 'j2kDecoder'); const segmentPath = await this.yuvToTsSegment(j2kDecoder, sampleRate[0] / sampleRate[1], fieldOrder); Profiler.tag(this.segment.id, 'endYuvToTs'); fs.unlink(j2kDecoder.yuvPathWithSuffix as string, () => null); fs.unlink(j2kDecoder.mjcPath as string, () => null); Honeycomb.endSpan(spanYuvToTsSegment); return segmentPath; } const acesDecoder = new AcesDecoder(mxfFileReader); return acesDecoder.decode(startFrameIndex, lastFrameIndex); } private async movTranscoder(signedUrl: string, signedMxfIndexUrl: Nullable): Promise { const segmentPath = `${__dirname}/../../media/${uuid()}.ts`; const { ffmpegCommand, ffmpegPromise } = createFFmpegCommand({ ffmpegTimeout: 40, }) as TrackedFFmpegCommand; ffmpegCommand.input(`cache:${signedUrl}`); ffmpegCommand.inputOptions([ '-ss', `${this.segment.startTime}`, '-t', `${this.segment.duration}`, ]); if (signedMxfIndexUrl) { ffmpegCommand.inputOptions(['-use_mxf_cache', signedMxfIndexUrl]); } ffmpegCommand.outputOptions([ `-map 0:${this.segment.streamIndex}`, '-muxdelay 0', '-copyts', '-c:v libx264', '-maxrate 5M', '-bufsize 3M', '-bf', '0', '-vf scale=-2:720', '-f', 'mpegts', '-profile:v', 'main', '-level', '4.0', '-pix_fmt', 'yuv420p', ]); ffmpegCommand.save(segmentPath); await ffmpegPromise; return segmentPath; } // Similar to mov transcoder, but mpeg2 is cut at a later timecode than it should be // First transcodes a few seconds more at the beginning and then cuts the result private async mpegtsTranscoder(signedUrl: string): Promise { const segmentPath = path.join(config.mediaPath, `${uuid()}.ts`); const tmpPath = path.join(config.mediaPath, `${uuid()}.ts`); const frameRate = this.segment.editRate; let { ffmpegCommand, ffmpegPromise } = createFFmpegCommand({ ffmpegTimeout: 40, }) as TrackedFFmpegCommand; ffmpegCommand.input(signedUrl); const durationOffset = Math.round(2.3 * frameRate) / frameRate; const firstStartTime = Math.max(0, this.segment.startTime - durationOffset); const firstDuration = this.segment.duration + durationOffset; try { ffmpegCommand.inputOptions([ '-ss', `${firstStartTime}`, '-t', `${firstDuration}`, ]); ffmpegCommand.outputOptions([ `-map 0:${this.segment.streamIndex}`, '-muxdelay 0', '-copyts', '-c:v libx264', '-maxrate 5M', '-bufsize 3M', '-bf', '0', '-vf scale=-2:720', '-f', 'mpegts', '-profile:v', 'main', '-level', '4.0', '-pix_fmt', 'yuv420p', ]); ffmpegCommand.save(tmpPath); await ffmpegPromise; } catch (err) { log.error(`Mpegts seek transcode failure with start: ${firstStartTime}, duration: ${firstDuration}`); throw err; } ({ ffmpegCommand, ffmpegPromise } = createFFmpegCommand({ ffmpegTimeout: 40 }) as TrackedFFmpegCommand); try { ffmpegCommand.input(tmpPath); ffmpegCommand.outputOptions([ '-ss', `${this.segment.startTime}`, '-t', `${this.segment.duration}`, '-copyts', '-c:v libx264', '-maxrate 5M', '-bufsize 3M', '-bf', '0', '-f', 'mpegts', '-profile:v', 'main', '-level', '4.0', '-pix_fmt', 'yuv420p', '-output_ts_offset', `${this.segment.totalStartTime}`, ]); ffmpegCommand.save(segmentPath); await ffmpegPromise; } catch (err) { log.error(`Mpegts second transcode failure with start: ${this.segment.startTime}, duration: ${this.segment.duration}`); throw err; } fs.unlinkSync(tmpPath); return segmentPath; } private getStartFrameIndex(): number { const frameIndex = this.segment.startTime * this.segment.editRate; if (Math.abs(frameIndex - Math.round(frameIndex)) >= 0.001) { throw new Error('Unable to identify with a good precision the start frame index!'); } return Math.round(frameIndex); } private getLastFrameIndex(startFrame: number): number { const framesCount = this.segment.duration * this.segment.editRate; if (Math.abs(framesCount - Math.round(framesCount)) >= 0.001) { throw new Error('Unable to compute with a good precision the number of frames!'); } return startFrame + Math.round(framesCount) - 1; } }