Add a progress bar to HTML export

Generation of HTML is extremely fast, but compressing thousands
of frames for wireframe animated GIFs can take a little while.

Sharing bitmaps between threads required two changes: (1) bitmaps
need to be "frozen" after being drawn; (2) you can't use Path because
BackgroundWorker isn't a STAThread.  You can, however, use a
DrawingVisual / DrawingContext to do the rendering.  Which is really
what I should have been doing all along; I just didn't know the
approach existed until I was forced to go looking for it.

Also, we now do a "run finalizers" call before generating an animated
GIF.  Without it things explode after more than 10K GDI objects have
been allocated.
This commit is contained in:
Andy McFadden 2020-03-15 11:49:11 -07:00
parent b3dacc2613
commit 1da98d8628
9 changed files with 146 additions and 28 deletions

View File

@ -107,7 +107,10 @@ namespace CommonWPF {
int percent = e.ProgressPercentage;
string msg = e.UserState as string;
Debug.Assert(percent >= 0 && percent <= 100);
if (percent < 0 || percent > 100) {
Debug.WriteLine("WorkProgress: bad percent " + percent);
percent = 0;
}
if (!string.IsNullOrEmpty(msg)) {
messageText.Text = msg;

View File

@ -318,7 +318,7 @@ namespace SourceGen.AsmGen.WpfGui {
}
private class AsmWorker : WorkProgress.IWorker {
IAssembler mAssembler;
private IAssembler mAssembler;
public AssemblerResults Results { get; private set; }
public AsmWorker(IAssembler asm) {

View File

@ -15,6 +15,7 @@
*/
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Text;
@ -446,13 +447,45 @@ namespace SourceGen {
private const string HTML_EXPORT_CSS_FILE = "SGStyle.css";
private const string LABEL_LINK_PREFIX = "Sym";
private class ExportWorker : WorkProgress.IWorker {
private Exporter mExporter;
private string mPathName;
private bool mOverwriteCss;
public bool Success { get; private set; }
public ExportWorker(Exporter exp, string pathName, bool overwriteCss) {
mExporter = exp;
mPathName = pathName;
mOverwriteCss = overwriteCss;
}
public object DoWork(BackgroundWorker worker) {
return mExporter.OutputToHtml(worker, mPathName, mOverwriteCss);
}
public void RunWorkerCompleted(object results) {
if (results != null) {
Success = (bool)results;
}
}
}
/// <summary>
/// Generates HTML output to the specified path.
/// </summary>
/// <param name="pathName">Full pathname of output file (including ".html"). This
/// defines the root directory if there are additional files.</param>
/// <param name="overwriteCss">If set, existing CSS file will be replaced.</param>
public void OutputToHtml(string pathName, bool overwriteCss) {
public void OutputToHtml(Window parent, string pathName, bool overwriteCss) {
ExportWorker ew = new ExportWorker(this, pathName, overwriteCss);
WorkProgress dlg = new WorkProgress(parent, ew, false);
if (dlg.ShowDialog() != true) {
Debug.WriteLine("Export unsuccessful");
} else {
Debug.WriteLine("Export complete");
}
}
private bool OutputToHtml(BackgroundWorker worker, string pathName, bool overwriteCss) {
string exportTemplate = RuntimeDataAccess.GetPathName(HTML_EXPORT_TEMPLATE);
string tmplStr;
try {
@ -463,7 +496,7 @@ namespace SourceGen {
pathName, ex.Message);
MessageBox.Show(msg, Res.Strings.ERR_FILE_GENERIC_CAPTION,
MessageBoxButton.OK, MessageBoxImage.Error);
return;
return false;
}
// We should only need the _IMG directory if there are visualizations.
@ -478,7 +511,7 @@ namespace SourceGen {
imageDirPath);
MessageBox.Show(msg, Res.Strings.ERR_FILE_GENERIC_CAPTION,
MessageBoxButton.OK, MessageBoxImage.Error);
return;
return false;
}
exists = true;
} catch (FileNotFoundException) {
@ -493,7 +526,7 @@ namespace SourceGen {
imageDirPath, ex.Message);
MessageBox.Show(msg, Res.Strings.ERR_FILE_GENERIC_CAPTION,
MessageBoxButton.OK, MessageBoxImage.Error);
return;
return false;
}
}
@ -501,6 +534,13 @@ namespace SourceGen {
mImageDirPath = imageDirPath;
}
if (mImageDirPath == null) {
worker.ReportProgress(0, Res.Strings.EXPORTING_HTML);
} else {
worker.ReportProgress(0, Res.Strings.EXPORTING_HTML_AND_IMAGES);
}
// Perform some quick substitutions. This could be done more efficiently,
// but we're only doing this on the template file, which should be small.
tmplStr = tmplStr.Replace("$ProjectName$", mProject.DataFileName);
@ -527,11 +567,13 @@ namespace SourceGen {
int splitPoint = tmplStr.IndexOf(CodeLinesStr);
if (splitPoint < 0) {
Debug.WriteLine("No place to put code");
return;
return false;
}
string template1 = tmplStr.Substring(0, splitPoint);
string template2 = tmplStr.Substring(splitPoint + CodeLinesStr.Length);
int lastProgressPerc = 0;
// Generate UTF-8 text, without a byte-order mark.
using (StreamWriter sw = new StreamWriter(pathName, false, new UTF8Encoding(false))) {
sw.Write(template1);
@ -545,12 +587,27 @@ namespace SourceGen {
}
GenerateHtmlLine(lineIndex, sw, sb);
if (worker.CancellationPending) {
break;
}
int perc = (lineIndex * 100) / mCodeLineList.Count;
if (perc != lastProgressPerc) {
lastProgressPerc = perc;
worker.ReportProgress(perc);
}
}
sw.WriteLine("</pre>\r\n");
sw.Write(template2);
}
if (worker.CancellationPending) {
Debug.WriteLine("Cancel requested, deleting " + pathName);
File.Delete(pathName);
return false;
}
string cssFile = RuntimeDataAccess.GetPathName(HTML_EXPORT_CSS_FILE);
string outputDir = Path.GetDirectoryName(pathName);
string outputPath = Path.Combine(outputDir, HTML_EXPORT_CSS_FILE);
@ -563,9 +620,11 @@ namespace SourceGen {
cssFile, outputPath, ex.Message);
MessageBox.Show(msg, Res.Strings.ERR_FILE_GENERIC_CAPTION,
MessageBoxButton.OK, MessageBoxImage.Error);
return;
return false;
}
}
return true;
}
/// <summary>

View File

@ -2345,18 +2345,18 @@ namespace SourceGen {
eport.Selection = selection;
}
// This is generally fast enough that I don't feel the need to create a
// progress window.
try {
Mouse.OverrideCursor = Cursors.Wait;
if (dlg.GenType == WpfGui.Export.GenerateFileType.Html) {
eport.OutputToHtml(dlg.PathName, dlg.OverwriteCss);
} else {
if (dlg.GenType == WpfGui.Export.GenerateFileType.Html) {
// Generating wireframe animations can be slow, so we need to use a
// progress dialog.
eport.OutputToHtml(mMainWin, dlg.PathName, dlg.OverwriteCss);
} else {
// Text output is generally very fast. Put up a wait cursor just in case.
try {
Mouse.OverrideCursor = Cursors.Wait;
eport.OutputToText(dlg.PathName, dlg.TextModeCsv);
} finally {
Mouse.OverrideCursor = null;
}
} finally {
Mouse.OverrideCursor = null;
}
}

View File

@ -81,6 +81,8 @@ limitations under the License.
<system:String x:Key="str_ErrProjectSaveFail">Unable to save project file</system:String>
<system:String x:Key="str_ErrTooLargeForPreview">[File was too large for preview window]</system:String>
<system:String x:Key="str_ErrValueIncompatibleWithMask">Symbol value is incompatible with current multi-mask</system:String>
<system:String x:Key="str_ExportingHtml">Exporting HTML...</system:String>
<system:String x:Key="str_ExportingHtmlAndImages">Exporting HTML and images...</system:String>
<system:String x:Key="str_ExternalFileBadDirFmt" xml:space="preserve">Symbol files and extension scripts must live in the application runtime directory ({0}) or project directory ({1}).&#x0d;&#x0d;File {2} lives elsewhere.</system:String>
<system:String x:Key="str_ExternalFileBadDirCaption">File Not In Runtime Directory</system:String>
<system:String x:Key="str_FileFilterAll">All files (*.*)|*.*</system:String>

View File

@ -143,6 +143,10 @@ namespace SourceGen.Res {
(string)Application.Current.FindResource("str_ErrTooLargeForPreview");
public static string ERR_VALUE_INCOMPATIBLE_WITH_MASK =
(string)Application.Current.FindResource("str_ErrValueIncompatibleWithMask");
public static string EXPORTING_HTML =
(string)Application.Current.FindResource("str_ExportingHtml");
public static string EXPORTING_HTML_AND_IMAGES =
(string)Application.Current.FindResource("str_ExportingHtmlAndImages");
public static string EXTERNAL_FILE_BAD_DIR_FMT =
(string)Application.Current.FindResource("str_ExternalFileBadDirFmt");
public static string EXTERNAL_FILE_BAD_DIR_CAPTION =

View File

@ -79,7 +79,12 @@ namespace SourceGen {
public override void SetThumbnail(IVisualizationWireframe visWire,
ReadOnlyDictionary<string, object> parms) {
base.SetThumbnail(visWire, parms);
mWireObj = WireframeObject.Create(visWire);
if (visWire == null) {
// Thumbnail cache is being cleared. Throw out the wireframe object too.
mWireObj = null;
} else {
mWireObj = WireframeObject.Create(visWire);
}
}
/// <summary>
@ -99,6 +104,20 @@ namespace SourceGen {
bool doPersp = Util.GetFromObjDict(VisGenParams, VisWireframe.P_IS_PERSPECTIVE, true);
bool doBfc = Util.GetFromObjDict(VisGenParams, VisWireframe.P_IS_BFC_ENABLED, false);
// Try to avoid System.Runtime.InteropServices.COMException (0x88980003):
// MILERR_WIN32ERROR (Exception from HRESULT: 0x88980003)
// The problem seems to be that the bitmaps are GDI handles, there's a hard limit of
// 10,000, and they don't get released until finalizers run. If we wait for
// pending finalizers the pool stays at a manageable level. If we poke the GC
// every time the memory graph looks better but I suspect there's a performance hit.
//GC.Collect();
GC.WaitForPendingFinalizers();
if (mWireObj == null) {
Debug.WriteLine("EncodeGif: wire obj is null");
frameCount = 1;
}
for (int frame = 0; frame < frameCount; frame++) {
BitmapSource bs = GenerateWireframeImage(mWireObj, dim,
curX, curY, curZ, doPersp, doBfc);

View File

@ -106,8 +106,11 @@ namespace SourceGen {
/// <summary>
/// Image to show when things are broken.
/// </summary>
public static readonly BitmapImage BROKEN_IMAGE =
new BitmapImage(new Uri("pack://application:,,,/Res/RedX.png"));
public static readonly BitmapImage BROKEN_IMAGE;
static Visualization() {
BROKEN_IMAGE = new BitmapImage(new Uri("pack://application:,,,/Res/RedX.png"));
BROKEN_IMAGE.Freeze();
}
/// <summary>
/// Image to overlay on animation visualizations.
@ -185,19 +188,24 @@ namespace SourceGen {
} else {
CachedImage = ConvertToBitmapSource(vis2d);
}
Debug.Assert(CachedImage.IsFrozen);
}
/// <summary>
/// Updates the cached thumbnail image.
/// </summary>
/// <param name="visWire">Visualization object.</param>
/// <param name="visWire">Visualization object, or null to clear the thumbnail.</param>
/// <param name="parms">Visualization parameters.</param>
public virtual void SetThumbnail(IVisualizationWireframe visWire,
ReadOnlyDictionary<string, object> parms) {
Debug.Assert(visWire != null);
Debug.Assert(parms != null);
WireframeObject wireObj = WireframeObject.Create(visWire);
CachedImage = GenerateWireframeImage(wireObj, THUMBNAIL_DIM, parms);
if (visWire == null) {
CachedImage = BROKEN_IMAGE;
} else {
Debug.Assert(parms != null);
WireframeObject wireObj = WireframeObject.Create(visWire);
CachedImage = GenerateWireframeImage(wireObj, THUMBNAIL_DIM, parms);
}
Debug.Assert(CachedImage.IsFrozen);
}
/// <summary>
@ -246,6 +254,7 @@ namespace SourceGen {
palette,
vis2d.GetPixels(),
vis2d.Width);
image.Freeze();
return image;
}
@ -274,11 +283,17 @@ namespace SourceGen {
/// </summary>
public static BitmapSource GenerateWireframeImage(WireframeObject wireObj,
double dim, int eulerX, int eulerY, int eulerZ, bool doPersp, bool doBfc) {
if (wireObj == null) {
// Can happen if the visualization generator is failing on stuff loaded from
// the project file.
return BROKEN_IMAGE;
}
// Generate the path geometry.
GeometryGroup geo = GenerateWireframePath(wireObj, dim, eulerX, eulerY, eulerZ,
doPersp, doBfc);
// Render Path to bitmap -- https://stackoverflow.com/a/23582564/294248
// Render geometry to bitmap -- https://stackoverflow.com/a/869767/294248
Rect bounds = geo.GetRenderBounds(null);
//Debug.WriteLine("RenderWF dim=" + dim + " bounds=" + bounds + ": " + wireObj);
@ -290,7 +305,18 @@ namespace SourceGen {
96,
96,
PixelFormats.Pbgra32);
//RenderOptions.SetEdgeMode(bitmap, EdgeMode.Aliased); <-- doesn't work?
//RenderOptions.SetEdgeMode(bitmap, EdgeMode.Aliased); <-- no apparent effect
DrawingVisual dv = new DrawingVisual();
using (DrawingContext dc = dv.RenderOpen()) {
dc.DrawRectangle(Brushes.Black, null, new Rect(0, 0, bounds.Width, bounds.Height));
Pen pen = new Pen(Brushes.White, 1.0);
dc.DrawGeometry(Brushes.White, pen, geo);
}
bitmap.Render(dv);
#if false
// Old way: render Path to bitmap -- https://stackoverflow.com/a/23582564/294248
// Clear the bitmap to black. (Is there an easier way?)
GeometryGroup bkgnd = new GeometryGroup();
@ -308,7 +334,9 @@ namespace SourceGen {
path.Measure(bounds.Size);
path.Arrange(bounds);
bitmap.Render(path);
#endif
bitmap.Freeze();
return bitmap;
}
@ -405,6 +433,7 @@ namespace SourceGen {
private static BitmapSource GenerateBlankImage() {
RenderTargetBitmap bmp = new RenderTargetBitmap(1, 1, 96.0, 96.0,
PixelFormats.Pbgra32);
bmp.Freeze();
return bmp;
}
@ -438,6 +467,7 @@ namespace SourceGen {
RenderTargetBitmap bmp = new RenderTargetBitmap(IMAGE_SIZE, IMAGE_SIZE, 96.0, 96.0,
PixelFormats.Pbgra32);
bmp.Render(visual);
bmp.Freeze();
return bmp;
}

View File

@ -167,6 +167,7 @@ namespace SourceGen {
public void RefreshNeeded() {
foreach (Visualization vis in mList) {
vis.SetThumbnail(null);
vis.SetThumbnail(null, null);
}
}