Fixed VFR frame drops, bigger AutoEnc safety buffer for NCNN, cleanup

This commit is contained in:
N00MKRAD
2020-12-15 14:46:33 +01:00
parent e7dae90c92
commit 4510bd6015
8 changed files with 78 additions and 121 deletions

View File

@@ -13,7 +13,7 @@ namespace Flowframes
{
static string hdrFilter = @"-vf select=gte(n\,%frNum%),zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p";
static string videoEncArgs = "-pix_fmt yuv420p -movflags +faststart -vf \"crop = trunc(iw / 2) * 2:trunc(ih / 2) * 2\"";
static string videoEncArgs = "-pix_fmt yuv420p -movflags +faststart";
static string divisionFilter = "\"crop = trunc(iw / 2) * 2:trunc(ih / 2) * 2\"";
static string pngComprArg = "-compression_level 3";
@@ -35,18 +35,15 @@ namespace Flowframes
public static async Task VideoToFrames(string inputFile, string frameFolderPath, bool deDupe, bool delSrc, Size size, bool timecodes = true, bool sceneDetect = false)
{
if(!sceneDetect) Logger.Log("Extracting video frames from input video...");
if (!sceneDetect) Logger.Log("Extracting video frames from input video...");
string sizeStr = (size.Width > 1 && size.Height > 1) ? $"-s {size.Width}x{size.Height}" : "";
IOUtils.CreateDir(frameFolderPath);
string timecodeStr = timecodes ? "-copyts -r 1000 -frame_pts true" : "";
string scnDetect = sceneDetect ? $"\"select='gt(scene,{Config.GetFloatString("scnDetectValue")})'\"," : "";
string scnDetect = sceneDetect ? $"-vf \"select='gt(scene,{Config.GetFloatString("scnDetectValue")})'\"" : "";
string mpStr = deDupe ? ((Config.GetInt("mpdecimateMode") == 0) ? mpDecDef : mpDecAggr) : "";
string vf = (scnDetect.Length > 2 || mpStr.Length > 2) ? $"-vf {scnDetect},{mpStr}".ListCommaFix() : "";
string pad = Padding.inputFrames.ToString();
string args = $"-i {inputFile.Wrap()} {pngComprArg} -vsync 0 -pix_fmt rgb24 {timecodeStr} -vf {scnDetect}{divisionFilter} {sizeStr} \"{frameFolderPath}/%{pad}d.png\"";
if (deDupe)
{
string mpStr = (Config.GetInt("mpdecimateMode") == 0) ? mpDecDef : mpDecAggr;
args = $"-i {inputFile.Wrap()} {pngComprArg} -vsync 0 -pix_fmt rgb24 {timecodeStr} -vf {scnDetect}{mpStr},{divisionFilter} {sizeStr} \"{frameFolderPath}/%{pad}d.png\"";
}
string args = $"-i {inputFile.Wrap()} {pngComprArg} -vsync 0 -pix_fmt rgb24 {timecodeStr} {vf} {sizeStr} \"{frameFolderPath}/%{pad}d.png\"";
AvProcess.LogMode logMode = Interpolate.currentInputFrameCount > 50 ? AvProcess.LogMode.OnlyLastLine : AvProcess.LogMode.Hidden;
await AvProcess.RunFfmpeg(args, logMode);
await Task.Delay(1);
@@ -54,7 +51,7 @@ namespace Flowframes
DeleteSource(inputFile);
}
public static async Task ImportImages (string inpath, string outpath, bool delSrc = false, bool showLog = true)
public static async Task ImportImages(string inpath, string outpath, bool delSrc = false, bool showLog = true)
{
if (showLog) Logger.Log("Importing images...");
IOUtils.CreateDir(outpath);
@@ -93,48 +90,24 @@ namespace Flowframes
string enc = useH265 ? "libx265" : "libx264";
string loopStr = (looptimes > 0) ? $"-stream_loop {looptimes}" : "";
string presetStr = $"-preset {Config.Get("ffEncPreset")}";
string args = $" {loopStr} -framerate {fps.ToString().Replace(",",".")} -i \"{inputDir}\\{prefix}%0{nums}d.{imgFormat}\" -c:v {enc} -crf {crf} {presetStr} {videoEncArgs} -threads {Config.GetInt("ffEncThreads")} -c:a copy {outPath.Wrap()}";
string args = $" {loopStr} -framerate {fps.ToString().Replace(",", ".")} -i \"{inputDir}\\{prefix}%0{nums}d.{imgFormat}\" -c:v {enc} -crf {crf} {presetStr} {videoEncArgs} -threads {Config.GetInt("ffEncThreads")} -c:a copy {outPath.Wrap()}";
await AvProcess.RunFfmpeg(args, AvProcess.LogMode.OnlyLastLine);
if (delSrc)
DeleteSource(inputDir);
}
public static async Task FramesToMp4Vfr(string framesFile, string outPath, bool useH265, int crf, float fps, bool inRate)
public static async Task FramesToMp4Vfr(string framesFile, string outPath, bool useH265, int crf, float fps, AvProcess.LogMode logMode = AvProcess.LogMode.OnlyLastLine)
{
if(logMode != AvProcess.LogMode.Hidden)
Logger.Log($"Encoding MP4 video with CRF {crf}...");
string enc = useH265 ? "libx265" : "libx264";
string presetStr = $"-preset {Config.Get("ffEncPreset")}";
string vsyncStr = Config.GetInt("vfrMode") == 0 ? "-vsync 1" : "-vsync 2";
string vfrFilename = Path.GetFileName(framesFile);
string args = $" {vsyncStr} -f concat ";
if (inRate)
args += $"-r {fps.ToString().Replace(",", ".")} -i {vfrFilename} ";
else
args += $"-i {vfrFilename} -r {fps.ToString().Replace(",", ".")} ";
args += $"-c:v {enc} -crf {crf} {presetStr} {videoEncArgs} -threads {Config.GetInt("ffEncThreads")} -c:a copy {outPath.Wrap()}";
await AvProcess.RunFfmpeg(args, framesFile.GetParentDir(), AvProcess.LogMode.OnlyLastLine);
string args = $"-vsync 2 -f concat -i {vfrFilename} -r {fps.ToString().Replace(",", ".")} -c:v {enc} -crf {crf} {presetStr} {videoEncArgs} -threads {Config.GetInt("ffEncThreads")} -c:a copy {outPath.Wrap()}";
await AvProcess.RunFfmpeg(args, framesFile.GetParentDir(), logMode);
}
public static async Task FramesToMp4VfrChunk(string framesFile, string outPath, bool useH265, int crf, float fps, bool inRate)
{
string enc = useH265 ? "libx265" : "libx264";
string presetStr = $"-preset {Config.Get("ffEncPreset")}";
string vsyncStr = Config.GetInt("vfrMode") == 0 ? "-vsync 1" : "-vsync 2";
string vfrFilename = Path.GetFileName(framesFile);
string args = $" {vsyncStr} -f concat ";
if (inRate)
args += $"-r {fps.ToString().Replace(",", ".")} -i {vfrFilename} ";
else
args += $"-i {vfrFilename} -r {fps.ToString().Replace(",", ".")} ";
args += $"-c:v {enc} -crf {crf} {presetStr} {videoEncArgs} -threads {Config.GetInt("ffEncThreads")} -c:a copy {outPath.Wrap()}";
await AvProcess.RunFfmpeg(args, framesFile.GetParentDir(), AvProcess.LogMode.Hidden);
}
public static async Task ConcatVideos (string concatFile, string outPath, float fps, int looptimes = -1)
public static async Task ConcatVideos(string concatFile, string outPath, float fps, int looptimes = -1)
{
Logger.Log($"Merging videos...");
string loopStr = (looptimes > 0) ? $"-stream_loop {looptimes}" : "";
@@ -144,7 +117,7 @@ namespace Flowframes
await AvProcess.RunFfmpeg(args, concatFile.GetParentDir(), AvProcess.LogMode.Hidden);
}
public static async Task ConvertFramerate (string inputPath, string outPath, bool useH265, int crf, float newFps, bool delSrc = false)
public static async Task ConvertFramerate(string inputPath, string outPath, bool useH265, int crf, float newFps, bool delSrc = false)
{
Logger.Log($"Changing video frame rate...");
string enc = useH265 ? "libx265" : "libx264";
@@ -155,18 +128,17 @@ namespace Flowframes
DeleteSource(inputPath);
}
public static async void FramesToApng (string inputDir, bool opti, int fps, string prefix, bool delSrc = false)
public static async void FramesToApng(string inputDir, bool opti, int fps, string prefix, bool delSrc = false)
{
int nums = IOUtils.GetFilenameCounterLength(Directory.GetFiles(inputDir, "*.png")[0], prefix);
string filter = "";
if(opti) filter = "-vf \"split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse\"";
string filter = opti ? "-vf \"split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse\"" : "";
string args = "-framerate " + fps + " -i \"" + inputDir + "\\" + prefix + "%0" + nums + "d.png\" -f apng -plays 0 " + filter + " \"" + inputDir + "-anim.png\"";
await AvProcess.RunFfmpeg(args, AvProcess.LogMode.OnlyLastLine);
if (delSrc)
DeleteSource(inputDir);
}
public static async void FramesToGif (string inputDir, bool palette, int fps, string prefix, bool delSrc = false)
public static async void FramesToGif(string inputDir, bool palette, int fps, string prefix, bool delSrc = false)
{
int nums = IOUtils.GetFilenameCounterLength(Directory.GetFiles(inputDir, "*.png")[0], prefix);
string filter = palette ? "-vf \"split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse\"" : "";
@@ -185,7 +157,7 @@ namespace Flowframes
await AvProcess.RunFfmpeg(args, framesFile.GetParentDir(), AvProcess.LogMode.OnlyLastLine);
}
public static async Task LoopVideo (string inputFile, int times, bool delSrc = false)
public static async Task LoopVideo(string inputFile, int times, bool delSrc = false)
{
string pathNoExt = Path.ChangeExtension(inputFile, null);
string ext = Path.GetExtension(inputFile);
@@ -195,7 +167,7 @@ namespace Flowframes
DeleteSource(inputFile);
}
public static async Task LoopVideoEnc (string inputFile, int times, bool useH265, int crf, bool delSrc = false)
public static async Task LoopVideoEnc(string inputFile, int times, bool useH265, int crf, bool delSrc = false)
{
string pathNoExt = Path.ChangeExtension(inputFile, null);
string ext = Path.GetExtension(inputFile);
@@ -207,7 +179,7 @@ namespace Flowframes
DeleteSource(inputFile);
}
public static async Task ChangeSpeed (string inputFile, float newSpeedPercent, bool delSrc = false)
public static async Task ChangeSpeed(string inputFile, float newSpeedPercent, bool delSrc = false)
{
string pathNoExt = Path.ChangeExtension(inputFile, null);
string ext = Path.GetExtension(inputFile);
@@ -219,20 +191,20 @@ namespace Flowframes
DeleteSource(inputFile);
}
public static async Task Encode (string inputFile, string vcodec, string acodec, int crf, int audioKbps = 0, bool delSrc = false)
public static async Task Encode(string inputFile, string vcodec, string acodec, int crf, int audioKbps = 0, bool delSrc = false)
{
string outPath = Path.ChangeExtension(inputFile, null) + "-convert.mp4";
string args = $" -i {inputFile.Wrap()} -c:v {vcodec} -crf {crf} -pix_fmt yuv420p -c:a {acodec} -b:a {audioKbps}k {outPath.Wrap()}";
if (string.IsNullOrWhiteSpace(acodec))
args = args.Replace("-c:a", "-an");
if(audioKbps < 0)
if (audioKbps < 0)
args = args.Replace($" -b:a {audioKbps}", "");
await AvProcess.RunFfmpeg(args, AvProcess.LogMode.OnlyLastLine);
if (delSrc)
DeleteSource(inputFile);
}
public static async Task ExtractAudio (string inputFile, string outFile) // https://stackoverflow.com/a/27413824/14274419
public static async Task ExtractAudio(string inputFile, string outFile) // https://stackoverflow.com/a/27413824/14274419
{
Logger.Log($"[FFCmds] Extracting audio from {inputFile} to {outFile}", true);
string ext = GetAudioExt(inputFile);
@@ -243,7 +215,7 @@ namespace Flowframes
File.Delete(outFile);
}
static string GetAudioExt (string videoFile)
static string GetAudioExt(string videoFile)
{
switch (GetAudioCodec(videoFile))
{
@@ -266,7 +238,7 @@ namespace Flowframes
}
string args = $" -i {inputFile.Wrap()} -stream_loop {looptimes} -i {audioPath.Wrap()} -shortest -c copy {tempPath.Wrap()}";
await AvProcess.RunFfmpeg(args, AvProcess.LogMode.Hidden);
if(AvProcess.lastOutputFfmpeg.Contains("Invalid data"))
if (AvProcess.lastOutputFfmpeg.Contains("Invalid data"))
{
Logger.Log("Failed to merge audio!");
return;
@@ -277,12 +249,12 @@ namespace Flowframes
File.Move(tempPath, movePath);
}
public static float GetFramerate (string inputFile)
public static float GetFramerate(string inputFile)
{
string args = $" -i {inputFile.Wrap()}";
string output = AvProcess.GetFfmpegOutput(args);
string[] entries = output.Split(',');
foreach(string entry in entries)
foreach (string entry in entries)
{
if (entry.Contains(" fps") && !entry.Contains("Input ")) // Avoid reading FPS from the filename, in case filename contains "fps"
{
@@ -296,7 +268,7 @@ namespace Flowframes
return 0f;
}
public static Size GetSize (string inputFile)
public static Size GetSize(string inputFile)
{
string args = $" -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 {inputFile.Wrap()}";
string output = AvProcess.GetFfprobeOutput(args);
@@ -325,7 +297,7 @@ namespace Flowframes
return 0;
}
static int ReadFrameCountFfprobe (string inputFile, bool readFramesSlow)
static int ReadFrameCountFfprobe(string inputFile, bool readFramesSlow)
{
string args = $" -v panic -select_streams v:0 -show_entries stream=nb_frames -of default=noprint_wrappers=1 {inputFile.Wrap()}";
if (readFramesSlow)
@@ -370,7 +342,7 @@ namespace Flowframes
return -1;
}
static string GetAudioCodec (string path)
static string GetAudioCodec(string path)
{
string args = $" -v panic -show_streams -select_streams a -show_entries stream=codec_name {path.Wrap()}";
string info = AvProcess.GetFfprobeOutput(args);
@@ -386,7 +358,7 @@ namespace Flowframes
return "";
}
static string GetFirstStreamInfo (string ffmpegOutput)
static string GetFirstStreamInfo(string ffmpegOutput)
{
foreach (string line in Regex.Split(ffmpegOutput, "\r\n|\r|\n"))
{
@@ -396,7 +368,7 @@ namespace Flowframes
return "";
}
static void DeleteSource (string path)
static void DeleteSource(string path)
{
Logger.Log("[FFCmds] Deleting input file/dir: " + path, true);

View File

@@ -155,5 +155,10 @@ namespace Flowframes
return str.Remove(place, stringToReplace.Length).Insert(place, replaceWith);
}
public static string ListCommaFix (this string str)
{
return str.Replace(" , ", " ").Replace(" ,", " ").Replace(", ", " ");
}
}
}

View File

@@ -177,8 +177,7 @@ namespace Flowframes.Magick
}
}
string testStr = "";
if (testRun) testStr = " [TestRun]";
string testStr = testRun ? " [TestRun]" : "";
if (Interpolate.canceled) return;
if (skipped)
@@ -238,16 +237,13 @@ namespace Flowframes.Magick
string line = dupeFrameLines[i];
sourceFrameNum++;
string paddedFilename = "";
string sourceFramePath = "";
string[] kvp = line.Split(':');
int currentInFrame = kvp[0].GetInt();
int currentDupesAmount = kvp[1].GetInt();
// Copy Source Frame
paddedFilename = sourceFrameNum.ToString().PadLeft(Padding.inputFrames, '0') + $".{ext}";
sourceFramePath = Path.Combine(path, paddedFilename);
string paddedFilename = sourceFrameNum.ToString().PadLeft(Padding.inputFrames, '0') + $".{ext}";
string sourceFramePath = Path.Combine(path, paddedFilename);
if(debugLog) Logger.Log("[Source] Moving " + Path.GetFileName(sourceFramePath) + " => " + outFrameNum + $".{ext}");
if (!IOUtils.TryCopy(sourceFramePath, Path.Combine(tempSubFolder, outFrameNum + $".{ext}")))
break;

View File

@@ -14,7 +14,7 @@ namespace Flowframes.Main
static string interpFramesFolder;
static string videoChunksFolder;
public static int chunkSize = 125; // Encode every n frames
public static int safetyBufferFrames = 25;
public static int safetyBufferFrames = 50; // Ignore latest n frames to avoid using images that haven't been fully encoded yet
public static List<string> encodedFrames = new List<string>();
public static List<string> unencodedFrames = new List<string>();
@@ -32,6 +32,8 @@ namespace Flowframes.Main
unencodedFrames.Clear();
chunkSize = GetChunkSize(IOUtils.GetAmountOfFiles(Interpolate.currentFramesPath, false, "*.png") * Interpolate.lastInterpFactor);
safetyBufferFrames = Interpolate.lastAi.aiName.ToUpper().Contains("NCNN") ? 60 : 30; // Use bigger safety buffer for NCNN
Logger.Log($"Starting AutoEncode MainLoop - Chunk Size: {chunkSize} Frames - Safety Buffer: {safetyBufferFrames} Frames", true);
int videoIndex = 1;

View File

@@ -69,16 +69,7 @@ namespace Flowframes.Main
string vfrFile = Path.Combine(framesPath.GetParentDir(), $"vfr-{i.lastInterpFactor}x.ini");
if (mode == i.OutMode.VidGif)
{
await FFmpegCommands.FramesToGifVfr(vfrFile, outPath, true, Config.GetInt("gifColors"));
// TODO: Remove old code once new code works well
// if (new DirectoryInfo(framesPath).GetFiles()[0].Extension != ".png")
// {
// Logger.Log("Converting output frames to PNG to encode with Gifski...");
// await Converter.Convert(framesPath, ImageMagick.MagickFormat.Png00, 20, "png", false);
// }
// await GifskiCommands.CreateGifFromFrames(i.currentOutFps.RoundToInt(), Config.GetInt("gifskiQ"), framesPath, outPath);
}
if (mode == i.OutMode.VidMp4)
{
@@ -86,7 +77,7 @@ namespace Flowframes.Main
bool h265 = Config.GetInt("mp4Enc") == 1;
int crf = h265 ? Config.GetInt("h265Crf") : Config.GetInt("h264Crf");
await FFmpegCommands.FramesToMp4Vfr(vfrFile, outPath, h265, crf, fps, i.constantFrameRate);
await FFmpegCommands.FramesToMp4Vfr(vfrFile, outPath, h265, crf, fps);
await MergeAudio(i.lastInputPath, outPath);
if (changeFps > 0)
@@ -95,8 +86,6 @@ namespace Flowframes.Main
Program.mainForm.SetStatus("Creating video with desired frame rate...");
await FFmpegCommands.ConvertFramerate(outPath, currentOutFile, h265, crf, changeFps, !keepOriginalFpsVid);
await MergeAudio(i.lastInputPath, currentOutFile);
//if (looptimes > 0)
// await Loop(newOutPath, looptimes);
}
if (looptimes > 0)
@@ -164,7 +153,7 @@ namespace Flowframes.Main
string vfrFile = Path.Combine(i.currentTempDir, $"vfr-chunk-temp.ini");
File.WriteAllLines(vfrFile, IOUtils.ReadLines(vfrFileOriginal).Skip(firstFrameNum * 2).Take(framesAmount * 2));
await FFmpegCommands.FramesToMp4VfrChunk(vfrFile, outPath, h265, crf, i.currentOutFps, i.constantFrameRate);
await FFmpegCommands.FramesToMp4Vfr(vfrFile, outPath, h265, crf, i.currentOutFps, AvProcess.LogMode.Hidden);
IOUtils.TryDeleteIfExists(vfrFile);
}

View File

@@ -106,27 +106,10 @@ namespace Flowframes
await FFmpegCommands.ExtractSceneChanges(inPath, Path.Combine(currentTempDir, Paths.scenesDir));
await Task.Delay(10);
}
Program.mainForm.SetStatus("Extracting frames from video...");
Size resolution = IOUtils.GetVideoRes(inPath);
int maxHeight = Config.GetInt("maxVidHeight");
if (resolution.Height > maxHeight)
{
float factor = (float)maxHeight / resolution.Height;
int width = (resolution.Width * factor).RoundToInt();
Logger.Log($"Video is bigger than the maximum - Downscaling to {width}x{maxHeight}.");
await FFmpegCommands.VideoToFrames(inPath, outPath, Config.GetInt("dedupMode") == 2, false, new Size(width, maxHeight));
}
else
{
await FFmpegCommands.VideoToFrames(inPath, outPath, Config.GetInt("dedupMode") == 2, false);
}
/*
if (AvProcess.lastOutputFfmpeg.ToLower().Contains("invalid"))
{
Cancel("Failed to read input video.");
return;
}
*/
await FFmpegCommands.VideoToFrames(inPath, outPath, Config.GetInt("dedupMode") == 2, false, Utils.GetOutputResolution(inPath));
if (extractAudio)
{
string audioFile = Path.Combine(currentTempDir, "audio.m4a");
@@ -163,11 +146,6 @@ namespace Flowframes
else
MagickDedupe.ClearCache();
int frameCountAfterDedupe = IOUtils.GetAmountOfFiles(currentFramesPath, false, "*.png");
int dupesPercent = 100 - (((float)frameCountAfterDedupe / currentInputFrameCount) * 100f).RoundToInt();
constantFrameRate = dupesPercent < 5f; // Ignore VFR timings for CFR input. TODO: Figure out how to avoid dupes when using VFR timings
Logger.Log($"{dupesPercent}% of frames are dupes, so constantFrameRate = {constantFrameRate}", true);
if (canceled) return;
bool useTimestamps = Config.GetInt("timingMode") == 1; // TODO: Auto-Disable timestamps if input frames are sequential, not timestamped

View File

@@ -101,22 +101,12 @@ namespace Flowframes.Main
InterpolateUtils.ShowMessage("Failed to delete existing frames folder - Make sure no file is opened in another program!", "Error");
return;
}
AiProcess.filenameMap.Clear();
bool extractAudio = true;
Program.mainForm.SetStatus("Extracting frames from video...");
Size resolution = IOUtils.GetVideoRes(currentInPath);
int maxHeight = Config.GetInt("maxVidHeight");
if (resolution.Height > maxHeight)
{
float factor = (float)maxHeight / resolution.Height;
int width = (resolution.Width * factor).RoundToInt();
Logger.Log($"Video is bigger than the maximum - Downscaling to {width}x{maxHeight}.");
await FFmpegCommands.VideoToFrames(currentInPath, currentFramesPath, Config.GetInt("dedupMode") == 2, false, new Size(width, maxHeight));
}
else
{
await FFmpegCommands.VideoToFrames(currentInPath, currentFramesPath, Config.GetInt("dedupMode") == 2, false);
}
await FFmpegCommands.VideoToFrames(currentInPath, currentFramesPath, Config.GetInt("dedupMode") == 2, false, InterpolateUtils.GetOutputResolution(currentInPath));
if (extractAudio)
{
string audioFile = Path.Combine(currentTempDir, "audio.m4a");

View File

@@ -260,5 +260,30 @@ namespace Flowframes.Main
MessageBox.Show(msg, title);
Logger.Log("Message: " + msg, true);
}
public static Size GetOutputResolution (string inputPath)
{
Size resolution = IOUtils.GetVideoRes(inputPath);
int maxHeight = RoundDiv2(Config.GetInt("maxVidHeight"));
if (resolution.Height > maxHeight)
{
float factor = (float)maxHeight / resolution.Height;
Logger.Log($"Un-rounded downscaled size: {(resolution.Width * factor).ToString("0.00")}x{Config.GetInt("maxVidHeight")}", true);
int width = RoundDiv2((resolution.Width * factor).RoundToInt());
Logger.Log($"Video is bigger than the maximum - Downscaling to {width}x{maxHeight}.");
return new Size(width, maxHeight);
}
else
{
return resolution;
}
}
public static int RoundDiv2(int n) // Round to a number that's divisible by 2 (for h264)
{
int a = (n / 2) * 2; // Smaller multiple
int b = a + 2; // Larger multiple
return (n - a > b - n) ? b : a; // Return of closest of two
}
}
}