using System; using System.Drawing; using System.Drawing.Imaging; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Threading.Tasks; using System.Windows.Forms; using FFmpegNet; using JD.Application.Utils; using JD.Asset.Types.Map; using JD.Audio.Annotations; using JD.Kernel.Classes; using JD.Serialization; using Newtonsoft.Json.Linq; using TeaBox.ObjectModel; using FrameConverter = JD.Core.FrameConverter; namespace JD.VideoPlayer.Project { /// /// Keeps all the video images in memory as a sprite sheet so that it is really fast to display video with different effects. /// public class VideoSpriteSheet { private const int MaxColumnCount = 100; private readonly Tape _tape; private readonly Scope _isGenerating = new Scope(); private Image _spriteSheet; public VideoSpriteSheet(Tape tape, Size singleImageSize) { _tape = tape; SingleImageSize = singleImageSize; } public Size SingleImageSize { get; } #region Generation /// /// Generates an atlas for the requested pngFolder /// public void GenerateSpriteSheet(string folder, string targetFile, IProgressMonitor progressMonitor) { if (_isGenerating.InScope) { return; } using (_isGenerating.Begin()) { var images = Directory.GetFiles(folder, "*.bmp").OrderBy(x => x).Select((imagePath, index) => new { imagePath, FrameIndex = index }).ToArray(); progressMonitor.TotalWork = images.Length; int rowCount = (int)Math.Ceiling(images.Length / (double)MaxColumnCount); using (var destinationImage = new Bitmap(MaxColumnCount * SingleImageSize.Width, rowCount * SingleImageSize.Height)) using (var graphics = Graphics.FromImage(destinationImage)) { Parallel.ForEach(images, imageToLoad => { using (var image = Image.FromFile(imageToLoad.imagePath)) { lock (destinationImage) { graphics.DrawImage(image, GetImageBounds(imageToLoad.FrameIndex)); progressMonitor.WorkDone++; } } }); destinationImage.Save(targetFile, ImageFormat.Png); } } } private Rectangle GetImageBounds(int frame) { var row = frame % MaxColumnCount; var column = frame / MaxColumnCount; return new Rectangle(new Point(row * SingleImageSize.Width, column * SingleImageSize.Height), SingleImageSize); } #endregion public void LoadFromSpriteSheet(string imagePath) { try { _spriteSheet = Image.FromFile(imagePath); } catch (Exception) // Typically a GDI exception { // When loading an image that is too large, it may fail // in that case, allow to open the editor but prompt a MessageService.MessageService.ShowBalloon(TimeSpan.FromSeconds(10), "Deactivating video preview", "Could not load video, preview will not be available", ToolTipIcon.Warning); } } #region Painting /// /// Paints the requested image on the graphics. /// public void Paint(Graphics graphics, BeatUnit beat, Rectangle destinationBounds) { Paint(graphics, ToVideoFrame(beat), destinationBounds); } /// /// Computes which frame in the video matches the specified frame in beat. /// private int ToVideoFrame(BeatUnit beat) { var wave = _tape.TryGetWave(); var time = wave.UnitConverter.TimeFromBeat(beat) - wave.Annotations.VideoStartTime; return (int)Math.Round(time.TotalSeconds / FrameConverter.VideoFrameDuration.TotalSeconds); } private void Paint(Graphics graphics, int frameId, Rectangle destinationBounds) { if (_spriteSheet != null) { var sourceBounds = GetImageBounds(frameId); AspectRatio.ScaleRectanglesKeepRatio(ref destinationBounds, ref sourceBounds); if (sourceBounds.Width > 0 && sourceBounds.Height > 0 && destinationBounds.Width > 0 && destinationBounds.Height > 0) { graphics.DrawImage(_spriteSheet, destinationBounds, sourceBounds, GraphicsUnit.Pixel); } } } private static class AspectRatio { public static void ScaleRectanglesKeepRatio(ref Rectangle destinationBounds, ref Rectangle sourceBounds) { double sourceRatio = sourceBounds.Width / (double)sourceBounds.Height; double destinationRatio = destinationBounds.Width / (double)destinationBounds.Height; if (destinationRatio < sourceRatio) { // Source image is proportionnaly larger than destination // use only the center of the source image and apply on target. int newImageWidth = (int)(sourceBounds.Height * destinationRatio); SetWidthPreserveCenter(ref sourceBounds, newImageWidth); } else { // Target image is proportionnaly larger than source // use all the source image and center it on destination. int newImageWidth = (int)(destinationBounds.Height * sourceRatio); SetWidthPreserveCenter(ref destinationBounds, newImageWidth); } } private static void SetWidthPreserveCenter(ref Rectangle bounds, int newWidth) { int toRemove = bounds.Width - newWidth; bounds.X += toRemove / 2; bounds.Width -= toRemove; } } #endregion } public static class VideoSpriteSheetExtensions { private const string _infoGeneratedOn = "GeneratedOn"; private const string _infoWebmMd5 = "WebmMd5"; private const string _infoSpriteSheetMd5 = "SpriteSheetMd5"; public static VideoSpriteSheet VideoSpriteSheet(this Tape tape) { var map = Map.FromPath(tape.Path); return tape.VideoSpriteSheet(System.IO.Path.Combine(map.VideoCoachFolder, "edition_video_spritesheet.png")); } public static VideoSpriteSheet VideoSpriteSheet(this Tape tape, string spritesheetFilePath) { JObject GetMetadata() { if (File.Exists(spritesheetFilePath + ".info")) { try { return JObject.Parse(File.ReadAllText(spritesheetFilePath + ".info")); } catch { } } return null; } VideoSpriteSheet spriteSheet = tape.Extensions.cache; if (spriteSheet == null) { spriteSheet = new VideoSpriteSheet(tape, new Size(200, 150)); tape.Extensions.cache = spriteSheet; var map = Map.FromPath(tape.Path); FileInfo spritesheetFile = new FileInfo(spritesheetFilePath); string webmFilePath = System.IO.Path.Combine(map.VideoCoachFolder, map.Name + ".webm"); JObject metadata = GetMetadata(); if (!spritesheetFile.Exists || metadata is null || metadata.Value(_infoWebmMd5) != GetHash(webmFilePath) || metadata.Value(_infoSpriteSheetMd5) != GetHash(spritesheetFilePath)) { GenerateSpriteSheet(spriteSheet, webmFilePath, spritesheetFilePath); } else { spriteSheet.LoadFromSpriteSheet(spritesheetFilePath); } } return spriteSheet; } private static string GetHash(string filePath) { using (Stream stream = File.OpenRead(filePath)) { return Convert.ToBase64String(MD5.Create().ComputeHash(stream)); } } private static void GenerateSpriteSheet(VideoSpriteSheet spritesheet, string webmFilePath, string spritesheetFilePath) { if (!File.Exists(webmFilePath)) { MessageService.MessageService.ShowBalloon(TimeSpan.FromSeconds(15), "Cannot generate Video Preview", $"file \"{webmFilePath}\" not found", ToolTipIcon.Warning); return; } TeaBox.ObjectModel.Application.Main.Dispatcher.DoLongOperationAsync(() => { using (var progress = TeaBox.ObjectModel.Application.Main.Status.ReportProgress("Generating optimized edition video", ProgressReportingType.ProgressDialog)) { string tempDirectory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString()); try { Directory.CreateDirectory(tempDirectory); FFmpegCommand command = new FFmpegCommand(); command.Inputs.Add(new FFmpegInput(webmFilePath)); command.OutputFileName = System.IO.Path.Combine(tempDirectory, "%5d.bmp"); command.Execute(); spritesheet.GenerateSpriteSheet(tempDirectory, spritesheetFilePath, progress); File.WriteAllText( spritesheetFilePath + ".info", new JObject { [_infoGeneratedOn] = DateTime.Now.ToString(), [_infoWebmMd5] = GetHash(webmFilePath), [_infoSpriteSheetMd5] = GetHash(spritesheetFilePath), }.ToString()); spritesheet.LoadFromSpriteSheet(spritesheetFilePath); } catch (Exception ex) { string message = "An error occurred generating the video sprite sheet. Details: " + ex.Message; MessageService.MessageService.ShowBalloon(TimeSpan.FromSeconds(10), "No Video Preview available", message, ToolTipIcon.Warning); } finally { Directory.Delete(tempDirectory, true); } } }); } } }