/// <summary> /// Generate single encoded video stream based on JOB and OUTPUT DEFINITION /// </summary> /// <param name="v">Defines the video stream being generated</param> /// <param name="j">The overall job for this encode run</param> /// <param name="xEncoder">libx264 or libx265</param> public void EncodeX(VideoOutputDefinition v, VideoEncodeJob j, string xEncoder) { MediaProperties ma = j.InputMedia[0]; MediaStream vs = ma.Streams[ma.FirstVideoIndex]; //implicitly set frame rate. //To do: this needs to be rationals, not floats var rateString = vs.VideoFrameRate > 0 ? $"-r {((j.MatchRate != 0) ? j.MatchRate : vs.VideoFrameRate)}" : String.Empty; //Select proper prefix based on input media. //MPEG files require a specific syntax on the input to prevent time-code issues. var FFRoot = this.MpegFiles.Contains(Path.GetExtension(ma.MediaFile).ToLower()) ? this.FFRootCmdMpg : this.FFRootCmdDefault; FilterChain f = this.ConfigureFilters(v, j); for (int pass = 1; pass <= v.NumPasses; pass++) { var FFCmd = $"{FFRoot} -i {j.InputMedia[0].MediaFile} {f.GetVideoFilters()} " + $"-pix_fmt {this.PixelFormatMap[j.PixelFmt]} " + $"-preset {this.EncoderPresetMap[j.Preset]} " + $"-an -c:v lib{xEncoder} " + $"-metadata:s:v:0 language={j.Language} " + //ToDo: this needs to be a rational like "24000/1001" to prevent rate drift during sitching or concetnation. $"{rateString} " + $"{this.BuildX26xEncOptions(v, j, pass, xEncoder)} " + $"{Path.Combine(j.OutputFolder, v.OutputFileName)}"; //Run this.utils.LogProxy($"\r\nSTARTING {xEncoder} Encode pass {pass} of {v.NumPasses}", Utilities.LogLevel.Info); this.ExecuteFFMpeg_LogProgress(FFCmd, ma.MediaDuration); } }
private string RunScan(MediaProperties ma, FilterChain fc, int startSeconds = 0, int scanDuration = 0) { var videoStream = ma.Streams.FirstOrDefault(m => m.StreamType == StreamType.Video); if (scanDuration == 0) { scanDuration = Convert.ToInt32(ma.MediaDuration); } var scanCmd = $" -ss {startSeconds} -y -i {ma.MediaFile} -pix_fmt yuv420p -t {scanDuration} {fc.GetVideoFilters()} -an -f null NUL"; return(this.ExecuteFFMpeg_LogProgress(scanCmd, scanDuration).StdErr); }
/// <summary> /// Parse the scan results to detect combed video or film content... /// </summary> /// <param name="ma"> MediaAttributes.</param> /// <param name="scanData">Results of FFMPEG run</param> private void ParseCombScanData(MediaProperties ma, string scanData) { string[] sfd = null; string[] mfd = null; string[] rfd = null; var fieldMatchFailures = 0; string[] scanDataLines = scanData.Split(new[] { "\n", "\r\n" }, StringSplitOptions.RemoveEmptyEntries); //Extract field and frame info foreach (string s in scanDataLines) { if (s.Contains("Repeated Fields")) { //remove dupe spaces string t = Regex.Replace(s, @"\s+", " ").Replace(@": ", @":"); rfd = t.Split(' '); } if (s.Contains("Single frame detection")) { //remove dupe spaces string t = Regex.Replace(s, @"\s+", " ").Replace(@": ", @":"); sfd = t.Split(' '); } if (s.Contains("Multi frame detection")) { //remove dupe spaces string t = Regex.Replace(s, @"\s+", " ").Replace(@": ", @":"); t = t.Replace(": ", ":"); mfd = t.Split(' '); } if (s.Contains("still interlaced")) { fieldMatchFailures++; } } if (sfd != null && mfd != null) { this.DoFrameAndFieldAnalysis(ma, mfd, rfd, fieldMatchFailures); } }
/// <summary> /// Scan media, looking for combing artifacts. /// Used for guiding de-interlacing and inverse-telecine filter selection. /// </summary> /// <param name="ma"> MediaAttributes.</param> /// <param name="startSeconds"></param> /// <param name="scanDuration"></param> public void DetectCombing(MediaProperties ma, int startSeconds = 0, int scanDuration = 0) { //Settings for detecting combing and telecine patterns. var filterIdet = "idet"; var filterFieldMatch = "fieldmatch=order=auto:combmatch=full:cthresh=12"; var filterChain = new FilterChain(); filterChain.AddFilter(filterIdet); filterChain.AddFilter(filterFieldMatch); this.utils.LogProxy($"Starting combing/telecine detection...", Utilities.LogLevel.Info); var scanData = this.RunScan(ma, filterChain, startSeconds, scanDuration); this.ParseCombScanData(ma, scanData); }
/// <summary> /// Derives x264/x265 options from JOB and OUTPUT DEFINITION /// This only supports the parameters that are common between x264 and x265 and relies in their shared syntax. /// VideoOutputDefinition.EncoderOptionOverride.Add() is used for unique encoder specific overrides. /// </summary> /// <param name="v">Defines the video stream being generated</param> /// <param name="j">The overall job for this encode run</param> /// <param name="pass">pass number of multi pass encode</param> /// <param name="xEncoder">encoder to use</param> protected string BuildX26xEncOptions(VideoOutputDefinition v, VideoEncodeJob j, int pass, string xEncoder) { MediaProperties ma = j.InputMedia[0]; MediaStream vs = ma.Streams[ma.FirstVideoIndex]; var statsFile = $"{Path.GetFileNameWithoutExtension(v.OutputFileName)}_STATS"; //Scene detection breaks GOP alignment across streams. Disable by default. //May be re-enabled at the stream level, for example for DONWLOAD encodes. var sceneDetection = v.AllowSceneDetection ? "" : "scenecut=0:"; //If job does not have color space for output implicitly set, derive it from source media. var ColorArgs = (j.ColorSpec == VideoEncodeJob.OutputColorSpec.Unknown) ? this.getX26xColorSpaceArgs(vs) : this.ColorSpaceMap[j.ColorSpec]; var args = $"-{xEncoder}-params \"" + $"bitrate={v.TargetBitrate}:" + $"vbv-maxrate={v.PeakBitrate}:" + $"vbv-bufsize={v.VBVBufferSize}:" + $"min-keyint={Convert.ToInt32(j.GopLengthSeconds * vs.VideoFrameRate)}:" + $"keyint={Convert.ToInt32(j.GopLengthSeconds * vs.VideoFrameRate)}:" + $"rc-lookahead={j.LookAheadFrames}:" + "open-gop=0:" + //GOPS must always be closed $"{sceneDetection}" + $"pass={pass}:" + $"stats=\"{statsFile}\":" + $"{ColorArgs}"; //handle HDR settings. //HDR is only supported when the output is HEVC if (xEncoder == "x265" && j.HdrMasteringDisplayPrimaries != null) { args += $":{this.Getx26xHdrString(j)}"; } foreach (string overrideOption in v.EncoderOptionOverride) { args += $":{overrideOption}"; } args += "\""; return(args); }
/// <summary> /// Adds input media to job /// </summary> /// <param name="m">MediaAttributes object describing a single input</param> public void AddInputMedia(MediaProperties m) { //Only one source per video encode job is permitted... if (this.InputMedia.Count == 1) { this.utils.LogProxy($"Video encoding jobs may have only one input...", Utilities.LogLevel.AppError); } //If present, fetch HDR data from source and add it to job //But only if it is not already set. //IE: trust delivered side-car metadata over embedded metadata if (m.HasHDR && this.HdrMasteringDisplayPrimaries == null) { this.HdrMasteringDisplayPrimaries = m.Streams[m.FirstVideoIndex].MasteringDisplayPrimaries; this.HdrMasteringDisplayLuminance = m.Streams[m.FirstVideoIndex].MasteringDisplayLuminance; this.HdrCea8613HdrData = m.Streams[m.FirstVideoIndex].Cea8613HdrData; } this.InputMedia.Add(m); }
/// <summary> /// Parse the scan results to detect combed video or film content... /// </summary> /// <param name="ma"> MediaAttributes.</param> /// <param name="mfd">Multi-frame detection data from FFMPEG IDET filter run</param> /// <param name="rfd">Repeat fields data from FFMPEG IDET filter run</param> /// <param name="fieldMatchFailures">Field match failures from FIELDMATCH filter run</param> private void DoFrameAndFieldAnalysis(MediaProperties ma, string[] mfd, string[] rfd, int fieldMatchFailures) { //Detection settings //If Progressive frame count falls below this level, combing //is assumed... decimal progressiveFrameThreshold = 95; decimal ProgFramePercentage = 0; decimal RepeatFieldPercentage = 0; decimal TfFtoBffRatio = 0; decimal mfdTff = 1, mfdBff = 1, mfdProg = 1; decimal rfdNone = 1, rfdTop = 1, rfdBottom = 1; foreach (string s in mfd) { if (s.Contains("TFF")) { mfdTff = Convert.ToDecimal(s.Split(':')[2]); } if (s.Contains("BFF")) { mfdBff = Convert.ToDecimal(s.Split(':')[1]); } if (s.Contains("Progressive")) { mfdProg = Convert.ToDecimal(s.Split(':')[1]); } } foreach (string s in rfd) { if (s.Contains("Neither")) { rfdNone = Convert.ToDecimal(s.Split(':')[2]); } if (s.Contains("Top")) { rfdTop = Convert.ToDecimal(s.Split(':')[1]); } if (s.Contains("Bottom")) { rfdBottom = Convert.ToDecimal(s.Split(':')[1]); } } //total number of identified frames. decimal processedFrames = rfdBottom + rfdNone + rfdTop; this.utils.LogProxy($"Total Scanned Frames (from IDET): {processedFrames}", Utilities.LogLevel.Info); this.utils.LogProxy($"Fieldmatch Failures: {fieldMatchFailures}", Utilities.LogLevel.Info); //Ratio of frames with repeated fields to the total number of frames identified RepeatFieldPercentage = ((rfdTop + rfdBottom) / processedFrames) * 100; this.utils.LogProxy($"RepeatFieldPercentage : {RepeatFieldPercentage:N2}", Utilities.LogLevel.Info); //Ratio of detected progressive frames to total frames identified ProgFramePercentage = (mfdProg / processedFrames) * 100; this.utils.LogProxy($"Progressive frame percentage : {ProgFramePercentage:N2}", Utilities.LogLevel.Info); //Compute ratio of TFF vs BFF frames. if (mfdTff == 0 || mfdBff == 0) { TfFtoBffRatio = 0; } else { TfFtoBffRatio = Math.Max(mfdTff, mfdBff) / Math.Min(mfdTff, mfdBff); } this.utils.LogProxy($"TFF:BFF ratio : {mfdTff}:{mfdBff}", Utilities.LogLevel.Info); //Once the detected progressive frame count falls below a threshold assume interlaced if (ProgFramePercentage < progressiveFrameThreshold) { ma.HasCombing = true; this.utils.LogProxy($"Combing detected...", Utilities.LogLevel.Info); } //If content is combed (low progressive frame count) and there are //repeated fields, video probably has some telecine content if (ma.HasCombing && (RepeatFieldPercentage > Convert.ToDecimal(5))) { this.utils.LogProxy($"Telecine content detected...", Utilities.LogLevel.Info); ma.HasTelecine = true; } //If fieldmatch failures are very low, indicating either clean progressive media //OR a clean telecine AND the progressive frame percentage is very low THEN //flag media a cleanly telecined. if (fieldMatchFailures / (double)processedFrames < 0.01 && ProgFramePercentage < 1) { ma.IsPureFilm = true; this.utils.LogProxy($"Clean Telecine content detected...", Utilities.LogLevel.Info); } ma.IsPureVideo = (ma.HasCombing && !ma.HasTelecine); ma.IsMixedFilmVideo = (ma.HasCombing && ma.HasTelecine && !ma.IsPureFilm); this.utils.LogProxy($"PureVideo:{ma.IsPureVideo}", Utilities.LogLevel.Info); this.utils.LogProxy($"Mixed Film and Video:{ma.IsMixedFilmVideo}", Utilities.LogLevel.Info); //If media is combed but the TFF:BFF ratio approaches 1:1 this could indicate //a potentially bad delivery. if (ma.HasCombing && (TfFtoBffRatio < Convert.ToDecimal(1.25) && TfFtoBffRatio > Convert.ToDecimal(0.75))) { ma.BadDelivery = true; this.utils.LogProxy($"Ratio of TFF and BFF frames of {TfFtoBffRatio:F}:1 indicates possibly poor delivery.", Utilities.LogLevel.Warning); } }
/// <summary> /// Manages filters used to process video. /// Scaling /// De-interlacing /// Cropping /// Matching to master (for stitching scenarios, IE: dub cards) /// Burning in sub-titles. /// </summary> /// <param name="outputVideo">Defines the video stream being generated</param> /// <param name="videoEncodeJob">The overall job for this encode run</param> protected FilterChain ConfigureFilters(VideoOutputDefinition outputVideo, VideoEncodeJob videoEncodeJob) { //Video stream MediaProperties videoProperties = videoEncodeJob.InputMedia[0]; MediaStream inputVideo = videoProperties.Streams[videoProperties.FirstVideoIndex]; FilterChain filters = new FilterChain(); //Derived from desired width, source aspect and cropping options. var TargetOutputHeight = 0; if (!String.IsNullOrEmpty(videoProperties.FFCropFilter) && videoEncodeJob.AutoCrop) { //must crop before any scaling happens... filters.AddFilter(videoProperties.FFCropFilter); if (inputVideo.VideoPixelAspect != 1) { inputVideo.SquareVideoWidth = Convert.ToInt32(videoProperties.cropValue.XExtent * inputVideo.VideoPixelAspect); inputVideo.SquareVideoHeight = videoProperties.cropValue.YExtent; } else { inputVideo.SquareVideoWidth = videoProperties.cropValue.XExtent; inputVideo.SquareVideoHeight = videoProperties.cropValue.YExtent; } } else { if (inputVideo.VideoPixelAspect != 1) { inputVideo.SquareVideoWidth = Convert.ToInt32(inputVideo.VideoWidth * inputVideo.VideoPixelAspect); } } //De-interlace filter behavior. //Must happen before any scaling operations. //Only invoke on HD and SD at non-film frame rates if (videoProperties.HasCombing && inputVideo.SquareVideoWidth <= 1920 && inputVideo.VideoFrameRate > 24) { if (videoEncodeJob.DeintOverride != VideoEncodeJob.DeinterlaceOverride.None) { //The job manager may desire to override behavior. //For example, in the event where the main feature is cleanly telecined film //but the credits are combed video. HBO LatAm does this. filters.AddFilter(this.DeinterlaceOverrideMap[videoEncodeJob.DeintOverride]); } else if (videoProperties.IsMixedFilmVideo) { //Typical: 30i animation with some telecine segments. //Preserve original frame rate. Inverse telecine film. //De-comb video. There will be judder in the film segments. filters.AddFilter(FFBase.DeintFilter_VideoBias); } else if (videoProperties.IsPureFilm) { //Old telecined content. Rare. filters.AddFilter(FFBase.DeintFilter_PureTelecine); } else if (videoProperties.IsPureVideo) { //Talk shows. Next day TV. Old sitcoms. //This filter setting is slow, but yields good results. filters.AddFilter(FFBase.DeintFilter_PureVideoSD); } } if (videoEncodeJob.MatchWidth != 0 && videoEncodeJob.MatchHeight != 0) { //Job indicates matching to master source. filters.AddFilter(this.GetScaleMatchFilter(inputVideo.VideoPixelAspect == 1, videoEncodeJob.MatchWidth, videoEncodeJob.MatchHeight)); TargetOutputHeight = outputVideo.Width * videoEncodeJob.MatchHeight / videoEncodeJob.MatchWidth; TargetOutputHeight = (TargetOutputHeight % 2 == 0) ? TargetOutputHeight : ++TargetOutputHeight; filters.AddFilter($"scale={outputVideo.Width}:{TargetOutputHeight}"); } else if (outputVideo.Width != inputVideo.SquareVideoWidth || inputVideo.VideoPixelAspect != 1) { //Input is either non-square or does not match desired output TargetOutputHeight = outputVideo.Width * inputVideo.SquareVideoHeight / inputVideo.SquareVideoWidth; TargetOutputHeight = (TargetOutputHeight % 2 == 0) ? TargetOutputHeight : ++TargetOutputHeight; filters.AddFilter($"scale={outputVideo.Width}:{TargetOutputHeight}"); } //Set source pixel aspect filters.AddFilter("setsar=1/1"); //Sub titles go last to ensure some degree of readability on low res streams if (!String.IsNullOrEmpty(videoEncodeJob.BurnSubs)) { //Need to escape characters so the ASS filter will be happy. videoEncodeJob.BurnSubs = videoEncodeJob.BurnSubs.Replace(@"\", @"\\"); videoEncodeJob.BurnSubs = videoEncodeJob.BurnSubs.Replace(@":", @"\:"); filters.AddFilter($"ass='{videoEncodeJob.BurnSubs}'"); } return(filters); }
/// <summary> /// Scan media, finding black bars and selecting the most common result. /// Used for automated cropping /// </summary> /// <param name="ma"> MediaAttributes.</param> /// <param name="startSeconds"></param> /// <param name="scanDuration"></param> public void DetectLetterbox(MediaProperties ma, int startSeconds = 0, int scanDuration = 0) { var filterCropdetect = "cropdetect=0.1:2:0"; var filterChain = new FilterChain(); var videoStream = ma.Streams.FirstOrDefault(m => m.StreamType == StreamType.Video); filterChain.AddFilter(filterCropdetect); this.utils.LogProxy($"Starting letterbox detection...", Utilities.LogLevel.Info); var scanData = this.RunScan(ma, filterChain, startSeconds, scanDuration); ma.cropValue = this.GetCommonCropValue(scanData); int bottomCrop = videoStream.VideoHeight - ma.cropValue.YExtent - ma.cropValue.YOffset; //If the bars at the top differ in size from those at the bottom //then re-scan because this is generally the result of noise at the top //of the video frame that breaks bar detection. if (Math.Abs(bottomCrop - ma.cropValue.YOffset) > 16) { filterChain.DeleteAll(); //Different masking values are used for suspected PAL and NSTC content. //The crop filter syntax is counter-intuitive. It describes the visible region of media //IE: crop=width:height:x-offset:y-offset <- this is the size of the output. Not the mask. if (videoStream.VideoHeight < 650 && videoStream.VideoHeight > 525) { filterChain.AddFilter($"crop={videoStream.VideoWidth.ToString()}:{videoStream.VideoHeight - 32}:0:{32}"); } else if (videoStream.VideoHeight <= 525) { filterChain.AddFilter($"crop={videoStream.VideoWidth.ToString()}:{videoStream.VideoHeight - 16}:0:{16}"); } filterChain.AddFilter(filterCropdetect); this.utils.LogProxy($"Starting second letterbox detection scan due to detected noise...", Utilities.LogLevel.Info); scanData = this.RunScan(ma, filterChain, startSeconds, scanDuration); ma.cropValue = this.GetCommonCropValue(scanData); if (videoStream.VideoHeight < 650 && videoStream.VideoHeight > 525) { ma.cropValue.YOffset += 32; } else if (videoStream.VideoHeight <= 525) { ma.cropValue.YOffset += 16; } } //Only crop if we have a minimum of letterbox coverage if (Math.Abs(ma.cropValue.YExtent - videoStream.VideoHeight) > 16) { ma.HasLetterbox = true; //Crop to even values... if (ma.cropValue.YExtent % 2 != 0) { ma.cropValue.YExtent--; } //Save pre-built crop filter for later use if desired. ma.FFCropFilter = $"crop={videoStream.VideoWidth.ToString()}:{ma.cropValue.YExtent.ToString()}:0:{ma.cropValue.YOffset.ToString()}"; } }