public unsafe bool Save(Project originalProject, int songId, int loopCount, string filename, int resX, int resY, bool halfFrameRate, int channelMask, int audioBitRate, int videoBitRate, float pianoRollZoom, bool stereo, float[] pan) { if (!Initialize(channelMask, loopCount)) { return(false); } videoResX = resX; videoResY = resY; var project = originalProject.DeepClone(); var song = project.GetSong(songId); ExtendSongForLooping(song, loopCount); // Save audio to temporary file. Log.LogMessage(LogSeverity.Info, "Exporting audio..."); var tempFolder = Utils.GetTemporaryDiretory(); var tempAudioFile = Path.Combine(tempFolder, "temp.wav"); AudioExportUtils.Save(song, tempAudioFile, SampleRate, 1, -1, channelMask, false, false, stereo, pan, (samples, samplesChannels, fn) => { WaveFile.Save(samples, fn, SampleRate, samplesChannels); }); // Start encoder, must be done before any GL calls on Android. GetFrameRateInfo(song.Project, halfFrameRate, out var frameRateNumer, out var frameRateDenom); if (!videoEncoder.BeginEncoding(videoResX, videoResY, frameRateNumer, frameRateDenom, videoBitRate, audioBitRate, tempAudioFile, filename)) { Log.LogMessage(LogSeverity.Error, "Error starting video encoder, aborting."); return(false); } Log.LogMessage(LogSeverity.Info, "Initializing channels..."); var numChannels = Utils.NumberOfSetBits(channelMask); var channelResXFloat = videoResX / (float)numChannels; var channelResX = videoResY; var channelResY = (int)channelResXFloat; var longestChannelName = 0.0f; var videoGraphics = RenderGraphics.Create(videoResX, videoResY, true); if (videoGraphics == null) { Log.LogMessage(LogSeverity.Error, "Error initializing off-screen graphics, aborting."); return(false); } var themeResources = new ThemeRenderResources(videoGraphics); var bmpWatermark = videoGraphics.CreateBitmapFromResource("VideoWatermark"); // Generate WAV data for each individual channel for the oscilloscope. var channelStates = new List <VideoChannelState>(); var maxAbsSample = 0; for (int i = 0, channelIndex = 0; i < song.Channels.Length; i++) { if ((channelMask & (1 << i)) == 0) { continue; } var pattern = song.Channels[i].PatternInstances[0]; var state = new VideoChannelState(); state.videoChannelIndex = channelIndex; state.songChannelIndex = i; state.channel = song.Channels[i]; state.channelText = state.channel.NameWithExpansion; state.wav = new WavPlayer(SampleRate, 1, 1 << i).GetSongSamples(song, song.Project.PalMode, -1); state.graphics = RenderGraphics.Create(channelResX, channelResY, false); state.bitmap = videoGraphics.CreateBitmapFromOffscreenGraphics(state.graphics); channelStates.Add(state); channelIndex++; // Find maximum absolute value to rescale the waveform. foreach (int s in state.wav) { maxAbsSample = Math.Max(maxAbsSample, Math.Abs(s)); } // Measure the longest text. longestChannelName = Math.Max(longestChannelName, state.graphics.MeasureString(state.channelText, themeResources.FontVeryLarge)); Log.ReportProgress(0.0f); } // Tweak some cosmetic stuff that depends on resolution. var smallChannelText = longestChannelName + 32 + ChannelIconTextSpacing > channelResY * 0.8f; var bmpSuffix = smallChannelText ? "" : "@2x"; var font = smallChannelText ? themeResources.FontMedium : themeResources.FontVeryLarge; var textOffsetY = smallChannelText ? 1 : 4; var pianoRollScaleX = Utils.Clamp(resY / 1080.0f, 0.6f, 0.9f); var pianoRollScaleY = channelResY < VeryThinNoteThreshold ? 0.5f : (channelResY < ThinNoteThreshold ? 0.667f : 1.0f); var channelLineWidth = channelResY < ThinNoteThreshold ? 3 : 5; var gradientSizeY = 256 * (videoResY / 1080.0f); var gradientBrush = videoGraphics.CreateVerticalGradientBrush(0, gradientSizeY, Color.Black, Color.FromArgb(0, Color.Black)); foreach (var s in channelStates) { s.bmpIcon = videoGraphics.CreateBitmapFromResource(ChannelType.Icons[s.channel.Type] + bmpSuffix); } // Generate the metadata for the video so we know what's happening at every frame var metadata = new VideoMetadataPlayer(SampleRate, 1).GetVideoMetadata(song, song.Project.PalMode, -1); var oscScale = maxAbsSample != 0 ? short.MaxValue / (float)maxAbsSample : 1.0f; var oscLookback = (metadata[1].wavOffset - metadata[0].wavOffset) / 2; // Setup piano roll and images. var pianoRoll = new PianoRoll(); pianoRoll.Move(0, 0, channelResX, channelResY); pianoRoll.SetThemeRenderResource(themeResources); pianoRoll.StartVideoRecording(channelStates[0].graphics, song, pianoRollZoom, pianoRollScaleX, pianoRollScaleY, out var noteSizeY); // Build the scrolling data. var numVisibleNotes = (int)Math.Floor(channelResY / (float)noteSizeY); ComputeChannelsScroll(metadata, channelMask, numVisibleNotes); if (song.UsesFamiTrackerTempo) { SmoothFamitrackerScrolling(metadata); } else { SmoothFamiStudioScrolling(metadata, song); } var oscWindowSize = (int)Math.Round(SampleRate * OscilloscopeWindowSize); var videoImage = new byte[videoResY * videoResX * 4]; var oscilloscope = new float[oscWindowSize, 2]; var success = true; #if !DEBUG try #endif { // Generate each of the video frames. for (int f = 0; f < metadata.Length; f++) { if (Log.ShouldAbortOperation) { success = false; break; } if ((f % 100) == 0) { Log.LogMessage(LogSeverity.Info, $"Rendering frame {f} / {metadata.Length}"); } Log.ReportProgress(f / (float)(metadata.Length - 1)); if (halfFrameRate && (f & 1) != 0) { continue; } var frame = metadata[f]; // Render the piano rolls for each channels. foreach (var s in channelStates) { var volume = frame.channelVolumes[s.songChannelIndex]; var note = frame.channelNotes[s.songChannelIndex]; var color = Color.Transparent; if (note.IsMusical) { if (s.channel.Type == ChannelType.Dpcm) { var mapping = project.GetDPCMMapping(note.Value); if (mapping != null) { color = mapping.Sample.Color; } } else { color = Color.FromArgb(128 + volume * 127 / 15, note.Instrument != null ? note.Instrument.Color : Theme.LightGreyFillColor1); } } s.graphics.BeginDrawFrame(); s.graphics.BeginDrawControl(new Rectangle(0, 0, channelResX, channelResY), channelResY); pianoRoll.RenderVideoFrame(s.graphics, s.channel.Index, frame.playPattern, frame.playNote, frame.scroll[s.songChannelIndex], note.Value, color); s.graphics.EndDrawControl(); s.graphics.EndDrawFrame(); } // Render the full screen overlay. videoGraphics.BeginDrawFrame(); videoGraphics.BeginDrawControl(new Rectangle(0, 0, videoResX, videoResY), videoResY); videoGraphics.Clear(Color.Black); var bg = videoGraphics.CreateCommandList(); var fg = videoGraphics.CreateCommandList(); // Composite the channel renders. foreach (var s in channelStates) { int channelPosX0 = (int)Math.Round(s.videoChannelIndex * channelResXFloat); bg.DrawBitmap(s.bitmap, channelPosX0, 0, s.bitmap.Size.Height, s.bitmap.Size.Width, 1.0f, 0, 0, 1, 1, true); } // Gradient fg.FillRectangle(0, 0, videoResX, gradientSizeY, gradientBrush); // Channel names + oscilloscope foreach (var s in channelStates) { int channelPosX0 = (int)Math.Round((s.videoChannelIndex + 0) * channelResXFloat); int channelPosX1 = (int)Math.Round((s.videoChannelIndex + 1) * channelResXFloat); var channelNameSizeX = (int)videoGraphics.MeasureString(s.channelText, font); var channelIconPosX = channelPosX0 + channelResY / 2 - (channelNameSizeX + s.bmpIcon.Size.Width + ChannelIconTextSpacing) / 2; fg.FillAndDrawRectangle(channelIconPosX, ChannelIconPosY, channelIconPosX + s.bmpIcon.Size.Width - 1, ChannelIconPosY + s.bmpIcon.Size.Height - 1, themeResources.DarkGreyLineBrush2, themeResources.LightGreyFillBrush1); fg.DrawBitmap(s.bmpIcon, channelIconPosX, ChannelIconPosY, 1, Theme.LightGreyFillColor1); fg.DrawText(s.channelText, font, channelIconPosX + s.bmpIcon.Size.Width + ChannelIconTextSpacing, ChannelIconPosY + textOffsetY, themeResources.LightGreyFillBrush1); if (s.videoChannelIndex > 0) { fg.DrawLine(channelPosX0, 0, channelPosX0, videoResY, themeResources.BlackBrush, channelLineWidth); } var oscMinY = (int)(ChannelIconPosY + s.bmpIcon.Size.Height + 10); var oscMaxY = (int)(oscMinY + 100.0f * (resY / 1080.0f)); // Intentionally flipping min/max Y since D3D is upside down compared to how we display waves typically. GenerateOscilloscope(s.wav, frame.wavOffset, oscWindowSize, oscLookback, oscScale, channelPosX0 + 10, oscMaxY, channelPosX1 - 10, oscMinY, oscilloscope); fg.DrawGeometry(oscilloscope, themeResources.LightGreyFillBrush1, 1, true); } // Watermark. fg.DrawBitmap(bmpWatermark, videoResX - bmpWatermark.Size.Width, videoResY - bmpWatermark.Size.Height); videoGraphics.DrawCommandList(bg); videoGraphics.DrawCommandList(fg); videoGraphics.EndDrawControl(); videoGraphics.EndDrawFrame(); // Readback videoGraphics.GetBitmap(videoImage); // Send to encoder. videoEncoder.AddFrame(videoImage); // Dump debug images. // DumpDebugImage(videoImage, videoResX, videoResY, f); } videoEncoder.EndEncoding(!success); File.Delete(tempAudioFile); } #if !DEBUG catch (Exception e) { Log.LogMessage(LogSeverity.Error, "Error exporting video."); Log.LogMessage(LogSeverity.Error, e.Message); } finally #endif { pianoRoll.EndVideoRecording(); foreach (var c in channelStates) { c.bmpIcon.Dispose(); c.bitmap.Dispose(); c.graphics.Dispose(); } themeResources.Dispose(); bmpWatermark.Dispose(); gradientBrush.Dispose(); videoGraphics.Dispose(); } return(true); }
public unsafe bool Save(Project originalProject, int songId, int loopCount, string ffmpegExecutable, string filename, int channelMask, int audioBitRate, int videoBitRate, int pianoRollZoom, bool thinNotes) { if (channelMask == 0 || loopCount < 1) { return(false); } Log.LogMessage(LogSeverity.Info, "Detecting FFmpeg..."); if (!DetectFFmpeg(ffmpegExecutable)) { return(false); } var project = originalProject.DeepClone(); var song = project.GetSong(songId); ExtendSongForLooping(song, loopCount); Log.LogMessage(LogSeverity.Info, "Initializing channels..."); var frameRate = song.Project.PalMode ? "5000773/100000" : "6009883/100000"; var numChannels = Utils.NumberOfSetBits(channelMask); var channelResXFloat = videoResX / (float)numChannels; var channelResX = videoResY; var channelResY = (int)channelResXFloat; var channelGraphics = new RenderGraphics(channelResX, channelResY); var videoGraphics = new RenderGraphics(videoResX, videoResY); var theme = RenderTheme.CreateResourcesForGraphics(videoGraphics); var bmpWatermark = videoGraphics.CreateBitmapFromResource("VideoWatermark"); // Generate WAV data for each individual channel for the oscilloscope. var channelStates = new List <VideoChannelState>(); List <short[]> channelsWavData = new List <short[]>(); var maxAbsSample = 0; for (int i = 0, channelIndex = 0; i < song.Channels.Length; i++) { if ((channelMask & (1 << i)) == 0) { continue; } var pattern = song.Channels[i].PatternInstances[0]; var state = new VideoChannelState(); state.videoChannelIndex = channelIndex; state.songChannelIndex = i; state.channel = song.Channels[i]; state.patternIndex = 0; state.channelText = state.channel.Name + (state.channel.IsExpansionChannel ? $" ({song.Project.ExpansionAudioShortName})" : ""); state.bmp = videoGraphics.CreateBitmapFromResource(Channel.ChannelIcons[song.Channels[i].Type] + "@2x"); // HACK: Grab the 200% scaled version directly. state.wav = new WavPlayer(sampleRate, 1, 1 << i).GetSongSamples(song, song.Project.PalMode, -1); channelStates.Add(state); channelIndex++; // Find maximum absolute value to rescale the waveform. foreach (short s in state.wav) { maxAbsSample = Math.Max(maxAbsSample, Math.Abs(s)); } } // Generate the metadata for the video so we know what's happening at every frame var metadata = new VideoMetadataPlayer(sampleRate, 1).GetVideoMetadata(song, song.Project.PalMode, -1); var oscScale = maxAbsSample != 0 ? short.MaxValue / (float)maxAbsSample : 1.0f; var oscLookback = (metadata[1].wavOffset - metadata[0].wavOffset) / 2; #if FAMISTUDIO_LINUX || FAMISTUDIO_MACOS var dummyControl = new DummyGLControl(); dummyControl.Move(0, 0, videoResX, videoResY); #endif // Setup piano roll and images. var pianoRoll = new PianoRoll(); #if FAMISTUDIO_LINUX || FAMISTUDIO_MACOS pianoRoll.Move(0, 0, channelResX, channelResY); #else pianoRoll.Width = channelResX; pianoRoll.Height = channelResY; #endif pianoRoll.StartVideoRecording(channelGraphics, song, pianoRollZoom, thinNotes, out var noteSizeY); // Build the scrolling data. var numVisibleNotes = (int)Math.Floor(channelResY / (float)noteSizeY); ComputeChannelsScroll(metadata, channelMask, numVisibleNotes); if (song.UsesFamiTrackerTempo) { SmoothFamiTrackerTempo(metadata); } var videoImage = new byte[videoResY * videoResX * 4]; var channelImage = new byte[channelResY * channelResX * 4]; var oscilloscope = new float[channelResY, 2]; #if FAMISTUDIO_LINUX || FAMISTUDIO_MACOS var badAlpha = DetectBadOpenGLAlpha(dummyControl, videoGraphics, videoImage); #endif // Start ffmpeg with pipe input. var tempFolder = Utils.GetTemporaryDiretory(); var tempVideoFile = Path.Combine(tempFolder, "temp.h264"); var tempAudioFile = Path.Combine(tempFolder, "temp.wav"); try { var process = LaunchFFmpeg(ffmpegExecutable, $"-y -f rawvideo -pix_fmt argb -s {videoResX}x{videoResY} -r {frameRate} -i - -c:v libx264 -pix_fmt yuv420p -b:v {videoBitRate}M -an \"{tempVideoFile}\"", true, false); // Generate each of the video frames. using (var stream = new BinaryWriter(process.StandardInput.BaseStream)) { for (int f = 0; f < metadata.Length; f++) { if (Log.ShouldAbortOperation) { break; } if ((f % 100) == 0) { Log.LogMessage(LogSeverity.Info, $"Rendering frame {f} / {metadata.Length}"); } Log.ReportProgress(f / (float)(metadata.Length - 1)); var frame = metadata[f]; // Render the full screen overlay. #if FAMISTUDIO_LINUX || FAMISTUDIO_MACOS videoGraphics.BeginDraw(dummyControl, videoResY); #else videoGraphics.BeginDraw(); #endif videoGraphics.Clear(Color.FromArgb(0, 0, 0, 0)); foreach (var s in channelStates) { int channelPosX0 = (int)Math.Round((s.videoChannelIndex + 0) * channelResXFloat); int channelPosX1 = (int)Math.Round((s.videoChannelIndex + 1) * channelResXFloat); var channelNameSizeX = videoGraphics.MeasureString(s.channelText, ThemeBase.FontBigUnscaled); var channelIconPosX = channelPosX0 + channelResY / 2 - (channelNameSizeX + s.bmp.Size.Width + channelIconTextSpacing) / 2; videoGraphics.FillRectangle(channelIconPosX, channelIconPosY, channelIconPosX + s.bmp.Size.Width, channelIconPosY + s.bmp.Size.Height, theme.LightGreyFillBrush1); videoGraphics.DrawBitmap(s.bmp, channelIconPosX, channelIconPosY); videoGraphics.DrawText(s.channelText, ThemeBase.FontBigUnscaled, channelIconPosX + s.bmp.Size.Width + channelIconTextSpacing, channelTextPosY, theme.LightGreyFillBrush1); if (s.videoChannelIndex > 0) { videoGraphics.DrawLine(channelPosX0, 0, channelPosX0, videoResY, theme.BlackBrush, 5); } GenerateOscilloscope(s.wav, frame.wavOffset, (int)Math.Round(sampleRate * oscilloscopeWindowSize), oscLookback, oscScale, channelPosX0 + 10, 60, channelPosX1 - 10, 160, oscilloscope); videoGraphics.AntiAliasing = true; videoGraphics.DrawLine(oscilloscope, theme.LightGreyFillBrush1); videoGraphics.AntiAliasing = false; } videoGraphics.DrawBitmap(bmpWatermark, videoResX - bmpWatermark.Size.Width, videoResY - bmpWatermark.Size.Height); videoGraphics.EndDraw(); videoGraphics.GetBitmap(videoImage); // Render the piano rolls for each channels. foreach (var s in channelStates) { s.volume = frame.channelVolumes[s.songChannelIndex]; s.note = frame.channelNotes[s.songChannelIndex]; var color = Color.Transparent; if (s.note.IsMusical) { if (s.channel.Type == Channel.Dpcm) { color = Color.FromArgb(210, ThemeBase.MediumGreyFillColor1); } else { color = Color.FromArgb(128 + s.volume * 127 / 15, s.note.Instrument != null ? s.note.Instrument.Color : ThemeBase.DarkGreyFillColor2); } } #if FAMISTUDIO_LINUX || FAMISTUDIO_MACOS channelGraphics.BeginDraw(pianoRoll, channelResY); #else channelGraphics.BeginDraw(); #endif pianoRoll.RenderVideoFrame(channelGraphics, Channel.ChannelTypeToIndex(s.channel.Type), frame.playPattern, frame.playNote, frame.scroll[s.songChannelIndex], s.note.Value, color); channelGraphics.EndDraw(); channelGraphics.GetBitmap(channelImage); // Composite the channel image with the full screen video overlay on the CPU. int channelPosX = (int)Math.Round(s.videoChannelIndex * channelResXFloat); int channelPosY = 0; for (int y = 0; y < channelResY; y++) { for (int x = 0; x < channelResX; x++) { int videoIdx = (channelPosY + x) * videoResX * 4 + (channelPosX + y) * 4; int channelIdx = (channelResY - y - 1) * channelResX * 4 + (channelResX - x - 1) * 4; byte videoA = videoImage[videoIdx + 3]; byte gradientA = (byte)(x < 255 ? 255 - x : 0); // Doing the gradient on CPU to look same on GL/D2D. byte channelR = channelImage[channelIdx + 0]; byte channelG = channelImage[channelIdx + 1]; byte channelB = channelImage[channelIdx + 2]; if (videoA != 0 || gradientA != 0) { #if FAMISTUDIO_LINUX || FAMISTUDIO_MACOS // Fix bad sRGB alpha. if (badAlpha) { videoA = SRGBToLinear[videoA]; } #endif videoA = Math.Max(videoA, gradientA); int videoR = videoImage[videoIdx + 0]; int videoG = videoImage[videoIdx + 1]; int videoB = videoImage[videoIdx + 2]; // Integer alpha blend. // Note that alpha is pre-multiplied, so we if we multiply again, image will look aliased. channelR = (byte)((channelR * (255 - videoA) + videoR * 255 /*videoA*/) >> 8); channelG = (byte)((channelG * (255 - videoA) + videoG * 255 /*videoA*/) >> 8); channelB = (byte)((channelB * (255 - videoA) + videoB * 255 /*videoA*/) >> 8); } // We byteswap here to match what ffmpeg expect. videoImage[videoIdx + 3] = channelR; videoImage[videoIdx + 2] = channelG; videoImage[videoIdx + 1] = channelB; videoImage[videoIdx + 0] = 255; // To export images to debug. //videoImage[videoIdx + 0] = channelR; //videoImage[videoIdx + 1] = channelG; //videoImage[videoIdx + 2] = channelB; //videoImage[videoIdx + 3] = 255; } } var prevChannelEndPosX = (int)Math.Round((s.videoChannelIndex - 1) * channelResXFloat) + channelResY; // HACK: Since we round the channels positions, we can end up with columns of pixels that arent byteswapped. if (s.videoChannelIndex > 0 && channelPosX != prevChannelEndPosX) { for (int y = 0; y < videoResY; y++) { int videoIdx = y * videoResX * 4 + (channelPosX - 1) * 4; byte videoR = videoImage[videoIdx + 0]; byte videoG = videoImage[videoIdx + 1]; byte videoB = videoImage[videoIdx + 2]; videoImage[videoIdx + 3] = videoR; videoImage[videoIdx + 2] = videoG; videoImage[videoIdx + 1] = videoB; videoImage[videoIdx + 0] = 255; } } } stream.Write(videoImage); // Dump debug images. #if FAMISTUDIO_LINUX || FAMISTUDIO_MACOS //var pb = new Gdk.Pixbuf(channelImage, true, 8, channelResX, channelResY, channelResX * 4); //pb.Save($"/home/mat/Downloads/channel.png", "png"); //var pb = new Gdk.Pixbuf(videoImage, true, 8, videoResX, videoResY, videoResX * 4); //pb.Save($"/home/mat/Downloads/frame_{f:D4}.png", "png"); #else //fixed (byte* vp = &videoImage[0]) //{ // var b = new System.Drawing.Bitmap(videoResX, videoResY, videoResX * 4, System.Drawing.Imaging.PixelFormat.Format32bppArgb, new IntPtr(vp)); // b.Save($"d:\\dump\\pr\\frame_{f:D4}.png"); //} #endif } } process.WaitForExit(); process.Dispose(); process = null; Log.LogMessage(LogSeverity.Info, "Exporting audio..."); // Save audio to temporary file. WaveFile.Save(song, tempAudioFile, sampleRate, 1, -1, channelMask); Log.LogMessage(LogSeverity.Info, "Mixing audio and video..."); // Run ffmpeg again to combine audio + video. process = LaunchFFmpeg(ffmpegExecutable, $"-y -i \"{tempVideoFile}\" -i \"{tempAudioFile}\" -c:v copy -c:a aac -b:a {audioBitRate}k \"{filename}\"", false, false); process.WaitForExit(); process.Dispose(); process = null; File.Delete(tempAudioFile); File.Delete(tempVideoFile); } catch (Exception e) { Log.LogMessage(LogSeverity.Error, "Error exporting video."); Log.LogMessage(LogSeverity.Error, e.Message); } finally { pianoRoll.EndVideoRecording(); foreach (var c in channelStates) { c.bmp.Dispose(); } theme.Terminate(); bmpWatermark.Dispose(); channelGraphics.Dispose(); videoGraphics.Dispose(); } return(true); }
public unsafe bool Save(Project originalProject, int songId, int loopCount, string ffmpegExecutable, string filename, int resX, int resY, bool halfFrameRate, int channelMask, int audioBitRate, int videoBitRate, int pianoRollZoom) { if (channelMask == 0 || loopCount < 1) { return(false); } Log.LogMessage(LogSeverity.Info, "Detecting FFmpeg..."); if (!DetectFFmpeg(ffmpegExecutable)) { return(false); } videoResX = resX; videoResY = resY; var project = originalProject.DeepClone(); var song = project.GetSong(songId); ExtendSongForLooping(song, loopCount); Log.LogMessage(LogSeverity.Info, "Initializing channels..."); var frameRateNumerator = song.Project.PalMode ? 5000773 : 6009883; if (halfFrameRate) { frameRateNumerator /= 2; } var frameRate = frameRateNumerator.ToString() + "/100000"; var numChannels = Utils.NumberOfSetBits(channelMask); var channelResXFloat = videoResX / (float)numChannels; var channelResX = videoResY; var channelResY = (int)channelResXFloat; var longestChannelName = 0.0f; var videoGraphics = RenderGraphics.Create(videoResX, videoResY, true); if (videoGraphics == null) { Log.LogMessage(LogSeverity.Error, "Error initializing off-screen graphics, aborting."); return(false); } var theme = RenderTheme.CreateResourcesForGraphics(videoGraphics); var bmpWatermark = videoGraphics.CreateBitmapFromResource("VideoWatermark"); // Generate WAV data for each individual channel for the oscilloscope. var channelStates = new List <VideoChannelState>(); List <short[]> channelsWavData = new List <short[]>(); var maxAbsSample = 0; for (int i = 0, channelIndex = 0; i < song.Channels.Length; i++) { if ((channelMask & (1 << i)) == 0) { continue; } var pattern = song.Channels[i].PatternInstances[0]; var state = new VideoChannelState(); state.videoChannelIndex = channelIndex; state.songChannelIndex = i; state.channel = song.Channels[i]; state.patternIndex = 0; state.channelText = state.channel.Name + (state.channel.IsExpansionChannel ? $" ({song.Project.ExpansionAudioShortName})" : ""); state.wav = new WavPlayer(SampleRate, 1, 1 << i).GetSongSamples(song, song.Project.PalMode, -1); state.graphics = RenderGraphics.Create(channelResX, channelResY, false); state.bitmap = videoGraphics.CreateBitmapFromOffscreenGraphics(state.graphics); channelStates.Add(state); channelIndex++; // Find maximum absolute value to rescale the waveform. foreach (short s in state.wav) { maxAbsSample = Math.Max(maxAbsSample, Math.Abs(s)); } // Measure the longest text. longestChannelName = Math.Max(longestChannelName, state.graphics.MeasureString(state.channelText, ThemeBase.FontBigUnscaled)); } // Tweak some cosmetic stuff that depends on resolution. var smallChannelText = longestChannelName + 32 + ChannelIconTextSpacing > channelResY * 0.8f; var bmpSuffix = smallChannelText ? "" : "@2x"; var font = smallChannelText ? ThemeBase.FontMediumUnscaled : ThemeBase.FontBigUnscaled; var textOffsetY = smallChannelText ? 1 : 4; var pianoRollScaleX = Utils.Clamp(resY / 1080.0f, 0.6f, 0.9f); var pianoRollScaleY = channelResY < VeryThinNoteThreshold ? 0.5f : (channelResY < ThinNoteThreshold ? 0.667f : 1.0f); var channelLineWidth = channelResY < ThinNoteThreshold ? 3 : 5; var gradientSizeY = 256 * (videoResY / 1080.0f); var gradientBrush = videoGraphics.CreateVerticalGradientBrush(0, gradientSizeY, Color.Black, Color.FromArgb(0, Color.Black)); foreach (var s in channelStates) { s.bmpIcon = videoGraphics.CreateBitmapFromResource(ChannelType.Icons[s.channel.Type] + bmpSuffix); } // Generate the metadata for the video so we know what's happening at every frame var metadata = new VideoMetadataPlayer(SampleRate, 1).GetVideoMetadata(song, song.Project.PalMode, -1); var oscScale = maxAbsSample != 0 ? short.MaxValue / (float)maxAbsSample : 1.0f; var oscLookback = (metadata[1].wavOffset - metadata[0].wavOffset) / 2; #if FAMISTUDIO_LINUX || FAMISTUDIO_MACOS var dummyControl = new DummyGLControl(); dummyControl.Move(0, 0, videoResX, videoResY); #endif // Setup piano roll and images. var pianoRoll = new PianoRoll(); #if FAMISTUDIO_LINUX || FAMISTUDIO_MACOS pianoRoll.Move(0, 0, channelResX, channelResY); #else pianoRoll.Width = channelResX; pianoRoll.Height = channelResY; #endif pianoRoll.StartVideoRecording(channelStates[0].graphics, song, pianoRollZoom, pianoRollScaleX, pianoRollScaleY, out var noteSizeY); // Build the scrolling data. var numVisibleNotes = (int)Math.Floor(channelResY / (float)noteSizeY); ComputeChannelsScroll(metadata, channelMask, numVisibleNotes); if (song.UsesFamiTrackerTempo) { SmoothFamitrackerScrolling(metadata); } else { SmoothFamiStudioScrolling(metadata, song); } var videoImage = new byte[videoResY * videoResX * 4]; var oscilloscope = new float[channelResY, 2]; // Start ffmpeg with pipe input. var tempFolder = Utils.GetTemporaryDiretory(); var tempAudioFile = Path.Combine(tempFolder, "temp.wav"); #if !DEBUG try #endif { Log.LogMessage(LogSeverity.Info, "Exporting audio..."); // Save audio to temporary file. WavMp3ExportUtils.Save(song, tempAudioFile, SampleRate, 1, -1, channelMask, false, false, (samples, fn) => { WaveFile.Save(samples, fn, SampleRate); }); var process = LaunchFFmpeg(ffmpegExecutable, $"-y -f rawvideo -pix_fmt argb -s {videoResX}x{videoResY} -r {frameRate} -i - -i \"{tempAudioFile}\" -c:v h264 -pix_fmt yuv420p -b:v {videoBitRate}K -c:a aac -b:a {audioBitRate}k \"{filename}\"", true, false); // Generate each of the video frames. using (var stream = new BinaryWriter(process.StandardInput.BaseStream)) { for (int f = 0; f < metadata.Length; f++) { if (Log.ShouldAbortOperation) { break; } if ((f % 100) == 0) { Log.LogMessage(LogSeverity.Info, $"Rendering frame {f} / {metadata.Length}"); } Log.ReportProgress(f / (float)(metadata.Length - 1)); if (halfFrameRate && (f & 1) != 0) { continue; } var frame = metadata[f]; // Render the piano rolls for each channels. foreach (var s in channelStates) { s.volume = frame.channelVolumes[s.songChannelIndex]; s.note = frame.channelNotes[s.songChannelIndex]; var color = Color.Transparent; if (s.note.IsMusical) { if (s.channel.Type == ChannelType.Dpcm) { var mapping = project.GetDPCMMapping(s.note.Value); if (mapping != null && mapping.Sample != null) { color = mapping.Sample.Color; } } else { color = Color.FromArgb(128 + s.volume * 127 / 15, s.note.Instrument != null ? s.note.Instrument.Color : ThemeBase.DarkGreyFillColor2); } } #if FAMISTUDIO_LINUX || FAMISTUDIO_MACOS s.graphics.BeginDraw(pianoRoll, channelResY); #else s.graphics.BeginDraw(); #endif pianoRoll.RenderVideoFrame(s.graphics, Channel.ChannelTypeToIndex(s.channel.Type), frame.playPattern, frame.playNote, frame.scroll[s.songChannelIndex], s.note.Value, color); s.graphics.EndDraw(); } // Render the full screen overlay. #if FAMISTUDIO_LINUX || FAMISTUDIO_MACOS videoGraphics.BeginDraw(dummyControl, videoResY); #else videoGraphics.BeginDraw(); #endif videoGraphics.Clear(Color.Black); // Composite the channel renders. foreach (var s in channelStates) { int channelPosX1 = (int)Math.Round((s.videoChannelIndex + 1) * channelResXFloat); videoGraphics.DrawRotatedFlippedBitmap(s.bitmap, channelPosX1, videoResY, s.bitmap.Size.Width, s.bitmap.Size.Height); } // Gradient videoGraphics.FillRectangle(0, 0, videoResX, gradientSizeY, gradientBrush); // Channel names + oscilloscope foreach (var s in channelStates) { int channelPosX0 = (int)Math.Round((s.videoChannelIndex + 0) * channelResXFloat); int channelPosX1 = (int)Math.Round((s.videoChannelIndex + 1) * channelResXFloat); var channelNameSizeX = videoGraphics.MeasureString(s.channelText, font); var channelIconPosX = channelPosX0 + channelResY / 2 - (channelNameSizeX + s.bmpIcon.Size.Width + ChannelIconTextSpacing) / 2; videoGraphics.FillRectangle(channelIconPosX, ChannelIconPosY, channelIconPosX + s.bmpIcon.Size.Width, ChannelIconPosY + s.bmpIcon.Size.Height, theme.DarkGreyLineBrush2); videoGraphics.DrawBitmap(s.bmpIcon, channelIconPosX, ChannelIconPosY); videoGraphics.DrawText(s.channelText, font, channelIconPosX + s.bmpIcon.Size.Width + ChannelIconTextSpacing, ChannelIconPosY + textOffsetY, theme.LightGreyFillBrush1); if (s.videoChannelIndex > 0) { videoGraphics.DrawLine(channelPosX0, 0, channelPosX0, videoResY, theme.BlackBrush, channelLineWidth); } var oscMinY = (int)(ChannelIconPosY + s.bmpIcon.Size.Height + 10); var oscMaxY = (int)(oscMinY + 100.0f * (resY / 1080.0f)); GenerateOscilloscope(s.wav, frame.wavOffset, (int)Math.Round(SampleRate * OscilloscopeWindowSize), oscLookback, oscScale, channelPosX0 + 10, oscMinY, channelPosX1 - 10, oscMaxY, oscilloscope); videoGraphics.AntiAliasing = true; videoGraphics.DrawLine(oscilloscope, theme.LightGreyFillBrush1); videoGraphics.AntiAliasing = false; } // Watermark. videoGraphics.DrawBitmap(bmpWatermark, videoResX - bmpWatermark.Size.Width, videoResY - bmpWatermark.Size.Height); videoGraphics.EndDraw(); // Readback + send to ffmpeg. videoGraphics.GetBitmap(videoImage); stream.Write(videoImage); // Dump debug images. // DumpDebugImage(videoImage, videoResX, videoResY, f); } } process.WaitForExit(); process.Dispose(); process = null; File.Delete(tempAudioFile); } #if !DEBUG catch (Exception e) { Log.LogMessage(LogSeverity.Error, "Error exporting video."); Log.LogMessage(LogSeverity.Error, e.Message); } finally #endif { pianoRoll.EndVideoRecording(); foreach (var c in channelStates) { c.bmpIcon.Dispose(); c.bitmap.Dispose(); c.graphics.Dispose(); } theme.Terminate(); bmpWatermark.Dispose(); gradientBrush.Dispose(); videoGraphics.Dispose(); } return(true); }
public bool Save(Project originalProject, int songId, int loopCount, int colorMode, int numColumns, int lineThickness, string filename, int resX, int resY, bool halfFrameRate, int channelMask, int audioBitRate, int videoBitRate, bool stereo, float[] pan) { if (!Initialize(channelMask, loopCount)) { return(false); } videoResX = resX; videoResY = resY; var project = originalProject.DeepClone(); var song = project.GetSong(songId); ExtendSongForLooping(song, loopCount); // Save audio to temporary file. Log.LogMessage(LogSeverity.Info, "Exporting audio..."); var tempFolder = Utils.GetTemporaryDiretory(); var tempAudioFile = Path.Combine(tempFolder, "temp.wav"); AudioExportUtils.Save(song, tempAudioFile, SampleRate, 1, -1, channelMask, false, false, stereo, pan, (samples, samplesChannels, fn) => { WaveFile.Save(samples, fn, SampleRate, samplesChannels); }); // Start encoder, must be done before any GL calls on Android. GetFrameRateInfo(song.Project, halfFrameRate, out var frameRateNumer, out var frameRateDenom); if (!videoEncoder.BeginEncoding(videoResX, videoResY, frameRateNumer, frameRateDenom, videoBitRate, audioBitRate, tempAudioFile, filename)) { Log.LogMessage(LogSeverity.Error, "Error starting video encoder, aborting."); return(false); } Log.LogMessage(LogSeverity.Info, "Initializing channels..."); var numChannels = Utils.NumberOfSetBits(channelMask); var longestChannelName = 0.0f; var videoGraphics = RenderGraphics.Create(videoResX, videoResY, true); if (videoGraphics == null) { Log.LogMessage(LogSeverity.Error, "Error initializing off-screen graphics, aborting."); return(false); } var themeResources = new ThemeRenderResources(videoGraphics); var bmpWatermark = videoGraphics.CreateBitmapFromResource("VideoWatermark"); // Generate WAV data for each individual channel for the oscilloscope. var channelStates = new List <VideoChannelState>(); var maxAbsSample = 0; for (int i = 0, channelIndex = 0; i < song.Channels.Length; i++) { if ((channelMask & (1 << i)) == 0) { continue; } var pattern = song.Channels[i].PatternInstances[0]; var state = new VideoChannelState(); state.videoChannelIndex = channelIndex; state.songChannelIndex = i; state.channel = song.Channels[i]; state.channelText = state.channel.NameWithExpansion; state.wav = new WavPlayer(SampleRate, 1, 1 << i).GetSongSamples(song, song.Project.PalMode, -1); channelStates.Add(state); channelIndex++; // Find maximum absolute value to rescale the waveform. foreach (int s in state.wav) { maxAbsSample = Math.Max(maxAbsSample, Math.Abs(s)); } // Measure the longest text. longestChannelName = Math.Max(longestChannelName, videoGraphics.MeasureString(state.channelText, themeResources.FontVeryLarge)); Log.ReportProgress(0.0f); } numColumns = Math.Min(numColumns, channelStates.Count); var numRows = (int)Math.Ceiling(channelStates.Count / (float)numColumns); var channelResXFloat = videoResX / (float)numColumns; var channelResYFloat = videoResY / (float)numRows; var channelResX = (int)channelResXFloat; var channelResY = (int)channelResYFloat; // Tweak some cosmetic stuff that depends on resolution. var smallChannelText = channelResY < 128; var bmpSuffix = smallChannelText ? "" : "@2x"; var font = lineThickness > 1 ? (smallChannelText ? themeResources.FontMediumBold : themeResources.FontVeryLargeBold) : (smallChannelText ? themeResources.FontMedium : themeResources.FontVeryLarge); var textOffsetY = smallChannelText ? 1 : 4; var channelLineWidth = resY >= 720 ? 5 : 3; foreach (var s in channelStates) { s.bmpIcon = videoGraphics.CreateBitmapFromResource(ChannelType.Icons[s.channel.Type] + bmpSuffix); } var gradientSizeY = channelResY / 2; var gradientBrush = videoGraphics.CreateVerticalGradientBrush(0, gradientSizeY, Color.Black, Color.FromArgb(0, Color.Black)); // Generate the metadata for the video so we know what's happening at every frame var metadata = new VideoMetadataPlayer(SampleRate, 1).GetVideoMetadata(song, song.Project.PalMode, -1); var oscScale = maxAbsSample != 0 ? short.MaxValue / (float)maxAbsSample : 1.0f; var oscLookback = (metadata[1].wavOffset - metadata[0].wavOffset) / 2; var oscWindowSize = (int)Math.Round(SampleRate * OscilloscopeWindowSize); BuildChannelColors(song, channelStates, metadata, colorMode); var videoImage = new byte[videoResY * videoResX * 4]; var oscilloscope = new float[oscWindowSize, 2]; var success = true; #if !DEBUG try #endif { // Generate each of the video frames. for (int f = 0; f < metadata.Length; f++) { if (Log.ShouldAbortOperation) { success = false; break; } if ((f % 100) == 0) { Log.LogMessage(LogSeverity.Info, $"Rendering frame {f} / {metadata.Length}"); } Log.ReportProgress(f / (float)(metadata.Length - 1)); if (halfFrameRate && (f & 1) != 0) { continue; } var frame = metadata[f]; videoGraphics.BeginDrawFrame(); videoGraphics.BeginDrawControl(new Rectangle(0, 0, videoResX, videoResY), videoResY); videoGraphics.Clear(Theme.DarkGreyLineColor2); var cmd = videoGraphics.CreateCommandList(); // Draw gradients. for (int i = 0; i < numRows; i++) { cmd.PushTranslation(0, i * channelResY); cmd.FillRectangle(0, 0, videoResX, channelResY, gradientBrush); cmd.PopTransform(); } // Channel names + oscilloscope for (int i = 0; i < channelStates.Count; i++) { var s = channelStates[i]; var channelX = i % numColumns; var channelY = i / numColumns; var channelPosX0 = (channelX + 0) * channelResX; var channelPosX1 = (channelX + 1) * channelResX; var channelPosY0 = (channelY + 0) * channelResY; var channelPosY1 = (channelY + 1) * channelResY; // Intentionally flipping min/max Y since D3D is upside down compared to how we display waves typically. GenerateOscilloscope(s.wav, frame.wavOffset, oscWindowSize, oscLookback, oscScale, channelPosX0, channelPosY1, channelPosX1, channelPosY0, oscilloscope); var brush = videoGraphics.GetSolidBrush(frame.channelColors[i]); cmd.DrawGeometry(oscilloscope, brush, lineThickness, true); var channelIconPosX = channelPosX0 + s.bmpIcon.Size.Width / 2; var channelIconPosY = channelPosY0 + s.bmpIcon.Size.Height / 2; cmd.FillAndDrawRectangle(channelIconPosX, channelIconPosY, channelIconPosX + s.bmpIcon.Size.Width - 1, channelIconPosY + s.bmpIcon.Size.Height - 1, themeResources.DarkGreyLineBrush2, themeResources.LightGreyFillBrush1); cmd.DrawBitmap(s.bmpIcon, channelIconPosX, channelIconPosY, 1, Theme.LightGreyFillColor1); cmd.DrawText(s.channelText, font, channelIconPosX + s.bmpIcon.Size.Width + ChannelIconTextSpacing, channelIconPosY + textOffsetY, themeResources.LightGreyFillBrush1); } // Grid lines for (int i = 1; i < numRows; i++) { cmd.DrawLine(0, i * channelResY, videoResX, i * channelResY, themeResources.BlackBrush, channelLineWidth); } for (int i = 1; i < numColumns; i++) { cmd.DrawLine(i * channelResX, 0, i * channelResX, videoResY, themeResources.BlackBrush, channelLineWidth); } // Watermark. cmd.DrawBitmap(bmpWatermark, videoResX - bmpWatermark.Size.Width, videoResY - bmpWatermark.Size.Height); videoGraphics.DrawCommandList(cmd); videoGraphics.EndDrawControl(); videoGraphics.EndDrawFrame(); // Readback videoGraphics.GetBitmap(videoImage); // Send to encoder. videoEncoder.AddFrame(videoImage); } videoEncoder.EndEncoding(!success); File.Delete(tempAudioFile); } #if !DEBUG catch (Exception e) { Log.LogMessage(LogSeverity.Error, "Error exporting video."); Log.LogMessage(LogSeverity.Error, e.Message); } finally #endif { foreach (var c in channelStates) { c.bmpIcon.Dispose(); } themeResources.Dispose(); bmpWatermark.Dispose(); gradientBrush.Dispose(); videoGraphics.Dispose(); } return(success); }