private static void Main(string[] args) { Parser.Default.ParseArguments <Options>(args) .WithParsed(opts => { if (!File.Exists(opts.AppConfig)) { Console.Error.WriteLine($"Configuration file not found: '{opts.AppConfig}'"); Environment.Exit(1); } var appConfig = JsonConvert.DeserializeObject <AppConfiguration>(File.ReadAllText(opts.AppConfig)); var animator = new Animator(appConfig); var outputDir = Directory.CreateDirectory(opts.OutputDir); if (File.Exists(opts.SongConfigFile)) { var songConfig = SongConfiguration.LoadFromFile(opts.SongConfigFile); Console.WriteLine($"Processing single song config: '{opts.SongConfigFile}'"); ProcessSong(ProgressReporterFactory(songConfig.OutputFilename), animator, songConfig, outputDir); Console.WriteLine("Finished."); Environment.Exit(0); } else { if (!Directory.Exists(opts.SongConfigDir)) { Console.Error.WriteLine($"Song config directory '{opts.SongConfigDir}' does not exist."); Environment.Exit(1); } var configFiles = Directory.GetFiles(opts.SongConfigDir, "*.txt"); if (!configFiles.Any()) { Console.Error.WriteLine($"No .txt lyric files found in directory '{opts.SongConfigDir}'"); Environment.Exit(1); } Console.WriteLine($"Processing {configFiles.Count()} configuration files from '{opts.SongConfigDir}'..."); foreach (var configFile in configFiles) { var songConfig = SongConfiguration.LoadFromFile(configFile); ProcessSong(ProgressReporterFactory(songConfig.OutputFilename), animator, songConfig, outputDir); Console.Write("\n"); } } });
public void Animate(Action <float> reportProgress, SongConfiguration config, string ffmpegExePath, DirectoryInfo outputDirectory, string pngOutputPath = null) { var desiredReadingY = height * 3 / 4; using (var titleTypeface = SKTypeface.FromFamilyName(appConfig.TitleFont.Family, SKFontStyleWeight.Light, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright)) using (var lyricTypeface = SKTypeface.FromFamilyName(appConfig.LyricsFont.Family, SKFontStyleWeight.Light, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright)) using (var verseTypeface = SKTypeface.FromFamilyName(appConfig.VerseFont.Family, SKFontStyleWeight.Light, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright)) { var timingRegex = new Regex(@"^(?:\[(\d{1,2}:\d{1,2}:\d{1,2})\])?\s*(?:\{([^}]+)\})?\s*(.*)$"); var unprocessedLines = File.ReadAllLines(config.LyricsFilePath); var speedChangeEasingFrames = appConfig.FramesPerSecond * 4; var processedLines = new List <(TimeSpan arrivalTime, TimeSpan nextArrivalTime, IEnumerable <string> lines)>(); var verseLabels = new List <VerseLabel>(); var segmentLines = new List <string>(); TimeSpan?segmentArrival = null; foreach (var line in unprocessedLines) { var match = timingRegex.Match(line); var nextArrivalGroup = match.Groups[1]; if (nextArrivalGroup.Success) { var nextArrivalTime = TimeSpan.Parse(nextArrivalGroup.Value); if (segmentLines.Any()) { processedLines.Add((segmentArrival.Value, nextArrivalTime, segmentLines)); segmentLines = new List <string>(); } segmentArrival = nextArrivalTime; // TODO: Make it clearer/explicit that verse text requires a paired segment timing if (match.Groups[2].Success) { var arrivalFrame = (int)(segmentArrival.Value.TotalSeconds * appConfig.FramesPerSecond); if (verseLabels.Any()) { verseLabels.Last().HiddenFrame = arrivalFrame - (int)(verseLabelHideBeforeVerseEnd.TotalSeconds * appConfig.FramesPerSecond); } verseLabels.Add(new VerseLabel { ArrivalFrame = arrivalFrame, Text = match.Groups[2].Value }); } } var lyricText = match.Groups[3].Value; segmentLines.Add(lyricText); } if (verseLabels.Any()) { verseLabels.Last().HiddenFrame = (int)(processedLines.Last().nextArrivalTime.TotalSeconds *appConfig.FramesPerSecond); } float?previousPixelsPerFrame = null; var speedChanges = new List <(int arrivalFrame, float fromPixelsPerFrame, float toPixelsPerFrame)>(); var lyrics = new List <Lyric>(); foreach (var timingSegment in processedLines) { // Calculate the speed of this segment, defined by (distance / duration) var segmentHeight = CalculateTextHeight( lyricTypeface, appConfig.LyricsFont.Size, timingSegment.lines, appConfig.LyricsFont.Size + appConfig.LyricsFont.LineMargin, width - sideMargin * 2 ); var segmentDuration = timingSegment.nextArrivalTime - timingSegment.arrivalTime; var pixelsPerFrame = (float)(segmentHeight / segmentDuration.TotalSeconds / appConfig.FramesPerSecond); var arrivalFrame = (int)(timingSegment.arrivalTime.TotalSeconds * appConfig.FramesPerSecond); speedChanges.Add(( arrivalFrame, previousPixelsPerFrame.GetValueOrDefault(pixelsPerFrame), pixelsPerFrame )); var segmentDurationFrames = segmentDuration.TotalSeconds * appConfig.FramesPerSecond; previousPixelsPerFrame = pixelsPerFrame; lyrics.Add(new Lyric { VisibleFrame = (int)(arrivalFrame - (appConfig.OutputDimensions.Height - desiredReadingY) / pixelsPerFrame - 100), HiddenFrame = (int)(arrivalFrame + segmentDurationFrames + appConfig.OutputDimensions.Height / pixelsPerFrame), ArrivalFrame = arrivalFrame, Lines = timingSegment.lines, Height = segmentHeight }); } var currentPixelsPerFrame = speedChanges.First().fromPixelsPerFrame; var currentSpeedChangeIndex = 1; var speedChangeStartFrame = speedChanges.Count > 1 ? speedChanges[1].arrivalFrame - speedChangeEasingFrames / 2 : new int?(); float?accelerationPerFrame = null; var endTransitionDissolveDurationFrames = (int)(endTransitionDuration.TotalSeconds * appConfig.FramesPerSecond); // TODO: Can we calculate total frames required from the song end time automatically? // Or do we need an explicit "end of audio" timestamp in the lyrics file? var duration = TimeSpan.Parse(config.Duration); var totalFramesRequired = duration.TotalSeconds * appConfig.FramesPerSecond; var outputFilePath = Path.Combine(outputDirectory.FullName, config.OutputFilename); File.Delete(outputFilePath); var ffmpegProcess = StartFfmpeg(appConfig, config.AudioFilePath, outputFilePath); var info = new SKImageInfo(width, height); using (var surface = SKSurface.Create(info)) { var canvas = surface.Canvas; for (var frame = 0; frame <= totalFramesRequired; frame++) { reportProgress(frame / (float)totalFramesRequired); canvas.Clear(SKColors.Black); if (speedChangeStartFrame.HasValue) { if (frame == speedChangeStartFrame.Value + speedChangeEasingFrames) { currentSpeedChangeIndex++; speedChangeStartFrame = speedChanges.Count > currentSpeedChangeIndex ? speedChanges[currentSpeedChangeIndex].arrivalFrame - speedChangeEasingFrames / 2 : new int?(); accelerationPerFrame = null; } else if (frame >= speedChangeStartFrame) { accelerationPerFrame = accelerationPerFrame ?? (speedChanges[currentSpeedChangeIndex].toPixelsPerFrame - currentPixelsPerFrame) / speedChangeEasingFrames; currentPixelsPerFrame += accelerationPerFrame.Value; } } for (var i = 0; i < lyrics.Count; i++) { var lyric = lyrics[i]; if (frame < lyric.VisibleFrame) { continue; } if (frame == lyric.VisibleFrame) { lyric.Y = i > 0 ? lyrics[i - 1].Y + lyrics[i - 1].Height : desiredReadingY + (lyric.ArrivalFrame - lyric.VisibleFrame) * currentPixelsPerFrame; } else { lyric.Y -= currentPixelsPerFrame; } DrawLyric( canvas, lyricTypeface, appConfig.LyricsFont.Size, appConfig.LyricsFont.Size + appConfig.LyricsFont.LineMargin, lyric.Lines, x: sideMargin, y: lyric.Y ); } DrawGradientOverlays(canvas); var framesToArrivalPointAtCurrentSpeed = (int)((appConfig.OutputDimensions.Height - desiredReadingY) / currentPixelsPerFrame); for (var i = 0; i < verseLabels.Count; i++) { var verseLabel = verseLabels[i]; var opacity = 1f; if (frame < verseLabel.ArrivalFrame - framesToArrivalPointAtCurrentSpeed) { continue; } if (frame == verseLabel.ArrivalFrame - framesToArrivalPointAtCurrentSpeed) { verseLabel.Y = appConfig.OutputDimensions.Height; } else if (frame > verseLabel.ArrivalFrame - framesToArrivalPointAtCurrentSpeed && verseLabel.Y > appConfig.OutputDimensions.HeaderHeight + appConfig.OutputDimensions.GradientHeight) { verseLabel.Y -= currentPixelsPerFrame; } else if (frame >= verseLabel.HiddenFrame) { opacity = Math.Max(0, 1 - (frame - verseLabel.HiddenFrame) / (float)DissolveAnimationDurationFrames); } DrawVerseLabel( canvas, verseTypeface, appConfig.VerseFont.Size, SKColor.Parse(appConfig.VerseFont.HexColor), verseLabel.Text, width - sideMargin, verseLabel.Y, opacity ); } DrawTitleAndFooterBars(canvas, titleTypeface, appConfig.TitleFont.Size, SKColor.Parse(appConfig.TitleFont.HexColor), config.SongTitle); if (totalFramesRequired - frame <= endTransitionDissolveDurationFrames) { var alpha = (1 - (totalFramesRequired - frame) / endTransitionDissolveDurationFrames) * 255; using (var paint = new SKPaint { Color = SKColors.Black.WithAlpha((byte)alpha) }) { canvas.DrawRect(new SKRect(0, 0, width, height), paint); } } using (var image = surface.Snapshot()) using (var data = image.Encode(SKEncodedImageFormat.Png, 100)) using (var ms = new MemoryStream(data.ToArray()) { Position = 0 }) { ms.WriteTo(ffmpegProcess.StandardInput.BaseStream); ms.Flush(); ffmpegProcess.StandardInput.BaseStream.Flush(); if (pngOutputPath != null) { using (var fs = new FileStream(Path.Combine(pngOutputPath, $"{frame:D5}.png"), FileMode.Create, FileAccess.Write)) { ms.Position = 0; ms.WriteTo(fs); ms.Flush(); fs.Close(); } } ms.Close(); } } } ffmpegProcess.StandardInput.BaseStream.Close(); ffmpegProcess.WaitForExit(); } }