├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── CI.yml ├── .gitignore ├── LICENSE.txt ├── Readme.md ├── Techsola.InstantReplay.sln ├── Techsola.InstantReplay.sln.DotSettings ├── build.ps1 ├── build ├── CiServerIntegration.ps1 ├── Get-DetectedCiVersion.ps1 └── SignTool.ps1 └── src ├── Directory.Build.props ├── Techsola.InstantReplay.Tests ├── InstantReplayCameraTests.cs ├── NativeTests.cs ├── PolyfillTests.cs └── Techsola.InstantReplay.Tests.csproj ├── Techsola.InstantReplay.snk ├── Techsola.InstantReplay ├── AnimatedCursorRenderer.cs ├── BasicCompletionSource.cs ├── CircularBuffer.cs ├── Color.cs ├── ColorEnumerable.cs ├── ColorEnumerator.cs ├── Composition.cs ├── DiffBoundsDetector.cs ├── Extensions.cs ├── Frame.cs ├── FrequencyLimit.cs ├── FrequencyLimiter.cs ├── GifWriter.GifImageDataChunker.cs ├── GifWriter.GifLzwBitPacker.cs ├── GifWriter.GraphNode.cs ├── GifWriter.cs ├── InstantReplayCamera.CompositionRenderer.cs ├── InstantReplayCamera.FrameSink.cs ├── InstantReplayCamera.WindowInfo.cs ├── InstantReplayCamera.cs ├── Native │ ├── DeleteDCSafeHandle.cs │ ├── ERROR.cs │ ├── UnownedHandle.cs │ └── WindowDeviceContextSafeHandle.cs ├── NativeMethods.txt ├── Polyfill │ ├── SupportedOSPlatformAttribute.cs │ └── ValueTuple.cs ├── SharedResultMutex.cs ├── Techsola.InstantReplay.csproj ├── UInt16Rectangle.cs ├── WindowEnumerator.cs ├── WindowMetrics.cs └── WuQuantizer.cs └── TestWinFormsApp ├── Program.cs └── TestWinFormsApp.csproj /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = crlf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.cs] 10 | indent_size = 4 11 | indent_style = space 12 | 13 | [*.{sln,*proj,dotsettings}] 14 | charset = utf-8-bom 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | 19 | [*] 20 | csharp_indent_case_contents_when_block = false 21 | dotnet_style_collection_initializer = true:silent 22 | csharp_style_conditional_delegate_call = true:error 23 | csharp_style_deconstructed_variable_declaration = true:silent 24 | dotnet_style_object_initializer = true:silent 25 | dotnet_sort_system_directives_first = true 26 | dotnet_code_quality_unused_parameters = all:silent 27 | dotnet_style_explicit_tuple_names = true:error 28 | dotnet_style_predefined_type_for_locals_parameters_members = true:error 29 | dotnet_style_predefined_type_for_member_access = true:error 30 | dotnet_style_readonly_field = true:error 31 | csharp_style_var_elsewhere = true:silent 32 | csharp_style_var_for_built_in_types = true:silent 33 | csharp_style_var_when_type_is_apparent = true:silent 34 | 35 | # Override ReSharper defaults 36 | csharp_space_after_cast = false 37 | resharper_csharp_space_within_single_line_array_initializer_braces = true # https://www.jetbrains.com/help/resharper/EditorConfig_CSHARP_SpacesPageSchema.html#resharper_csharp_space_within_single_line_array_initializer_braces 38 | 39 | # The first matching rule wins, more specific rules at the top 40 | # dotnet_naming_rule.*.symbols does not yet support a comma-separated list https://github.com/dotnet/roslyn/issues/20891 41 | # dotnet_naming_symbols.*.applicable_kinds does not yet support namespace, type_parameter or local https://github.com/dotnet/roslyn/issues/18121 42 | 43 | dotnet_naming_style.interfaces.required_prefix = I 44 | dotnet_naming_style.interfaces.capitalization = pascal_case # Needed or VS ignores all naming rules https://github.com/dotnet/roslyn/issues/20895 45 | 46 | dotnet_naming_symbols.interfaces.applicable_kinds = interface 47 | dotnet_naming_rule.interfaces.severity = error 48 | dotnet_naming_rule.interfaces.symbols = interfaces 49 | dotnet_naming_rule.interfaces.style = interfaces 50 | 51 | 52 | dotnet_naming_style.pascal_case.capitalization = pascal_case 53 | 54 | dotnet_naming_symbols.namespaces_types_and_non_field_members.applicable_kinds = namespace, class, struct, enum, interface, delegate, type_parameter, method, property, event 55 | dotnet_naming_rule.namespaces_types_and_non_field_members.severity = warning 56 | dotnet_naming_rule.namespaces_types_and_non_field_members.symbols = namespaces_types_and_non_field_members 57 | dotnet_naming_rule.namespaces_types_and_non_field_members.style = pascal_case 58 | 59 | dotnet_naming_symbols.non_private_fields.applicable_kinds = field 60 | dotnet_naming_symbols.non_private_fields.applicable_accessibilities = public, protected, protected_internal, internal 61 | dotnet_naming_rule.non_private_fields.severity = warning 62 | dotnet_naming_rule.non_private_fields.symbols = non_private_fields 63 | dotnet_naming_rule.non_private_fields.style = pascal_case 64 | 65 | dotnet_naming_symbols.static_readonly_fields.applicable_kinds = field 66 | dotnet_naming_symbols.static_readonly_fields.required_modifiers = static, readonly 67 | dotnet_naming_rule.static_readonly_fields.severity = warning 68 | dotnet_naming_rule.static_readonly_fields.symbols = static_readonly_fields 69 | dotnet_naming_rule.static_readonly_fields.style = pascal_case 70 | 71 | dotnet_naming_symbols.constant_fields.applicable_kinds = field 72 | dotnet_naming_symbols.constant_fields.required_modifiers = const 73 | dotnet_naming_rule.constant_fields.severity = warning 74 | dotnet_naming_rule.constant_fields.symbols = constant_fields 75 | dotnet_naming_rule.constant_fields.style = pascal_case 76 | 77 | 78 | dotnet_naming_style.camel_case.capitalization = camel_case 79 | 80 | dotnet_naming_symbols.other_fields_parameters_and_locals.applicable_kinds = field, parameter, local 81 | dotnet_naming_rule.other_fields_parameters_and_locals.severity = warning 82 | dotnet_naming_rule.other_fields_parameters_and_locals.symbols = other_fields_parameters_and_locals 83 | dotnet_naming_rule.other_fields_parameters_and_locals.style = camel_case 84 | 85 | 86 | # .NET diagnostic configuration 87 | 88 | # CS8509: The switch expression does not handle all possible inputs (it is not exhaustive). 89 | dotnet_diagnostic.CS8509.severity = silent 90 | # CS8524: The switch expression does not handle some values of its input type (it is not exhaustive) involving an unnamed enum value. 91 | dotnet_diagnostic.CS8524.severity = silent 92 | 93 | # IDE0005: Using directive is unnecessary. 94 | dotnet_diagnostic.IDE0005.severity = warning 95 | 96 | # CA1304: Specify CultureInfo 97 | dotnet_diagnostic.CA1304.severity = warning 98 | 99 | # CA1305: Specify IFormatProvider 100 | dotnet_diagnostic.CA1305.severity = warning 101 | 102 | # CA1310: Specify StringComparison for correctness 103 | dotnet_diagnostic.CA1310.severity = warning 104 | 105 | # CA1825: Avoid zero-length array allocations 106 | dotnet_diagnostic.CA1825.severity = warning 107 | 108 | # CA2016: Forward the 'CancellationToken' parameter to methods that take one 109 | dotnet_diagnostic.CA2016.severity = warning 110 | 111 | # CA2208: Instantiate argument exceptions correctly 112 | dotnet_diagnostic.CA2208.severity = warning 113 | 114 | # CA2211: Non-constant fields should not be visible 115 | dotnet_diagnostic.CA2211.severity = warning 116 | 117 | # CA2219: Do not raise exceptions in finally clauses 118 | dotnet_diagnostic.CA2219.severity = warning 119 | 120 | # CA2231: Overload operator equals on overriding value type Equals 121 | dotnet_diagnostic.CA2231.severity = warning 122 | 123 | # CA1806: Do not ignore method results 124 | dotnet_diagnostic.CA1806.severity = silent 125 | 126 | # CA1816: Dispose methods should call SuppressFinalize 127 | dotnet_diagnostic.CA1816.severity = none 128 | 129 | # CA1822: Mark members as static 130 | dotnet_diagnostic.CA1822.severity = silent 131 | 132 | # CA1826: Do not use Enumerable methods on indexable collections 133 | dotnet_diagnostic.CA1826.severity = silent 134 | 135 | # CA1834: Consider using 'StringBuilder.Append(char)' when applicable 136 | dotnet_diagnostic.CA1834.severity = silent 137 | 138 | # CA1806: Do not ignore method results 139 | dotnet_diagnostic.CA1806.severity = silent 140 | 141 | # CA2245: Do not assign a property to itself 142 | dotnet_diagnostic.CA2245.severity = silent 143 | 144 | # CA2201: Do not raise reserved exception types 145 | dotnet_diagnostic.CA2201.severity = warning 146 | 147 | # CA1805: Do not initialize unnecessarily 148 | dotnet_diagnostic.CA1805.severity = warning 149 | 150 | # CA1725: Parameter names should match base declaration 151 | dotnet_diagnostic.CA1725.severity = warning 152 | 153 | # IDE0001: Simplify Names 154 | dotnet_diagnostic.IDE0001.severity = warning 155 | 156 | # CA2215: Dispose methods should call base class dispose 157 | dotnet_diagnostic.CA2215.severity = warning 158 | 159 | # IDE0059: Unnecessary assignment of a value 160 | dotnet_diagnostic.IDE0059.severity = warning 161 | 162 | # CA1031: Do not catch general exception types 163 | dotnet_diagnostic.CA1031.severity = warning 164 | 165 | # CA1416: Validate platform compatibility 166 | dotnet_diagnostic.CA1416.severity = warning; 167 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ '*' ] 6 | pull_request: 7 | 8 | jobs: 9 | CI: 10 | 11 | runs-on: windows-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 # Needed in order for tags to be available so prereleases autoincrement the version 17 | 18 | - name: Build 19 | run: ./build.ps1 20 | 21 | - name: Publish to MyGet 22 | if: github.ref == 'refs/heads/main' 23 | run: dotnet nuget push artifacts\Packages\Techsola.InstantReplay.*.nupkg --source https://www.myget.org/F/techsola/api/v3/index.json --api-key ${{ secrets.MYGET_API_KEY }} 24 | 25 | - name: Upload packages artifact 26 | if: always() 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: Packages 30 | path: artifacts/Packages 31 | 32 | - name: Upload logs artifact 33 | if: always() 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: Logs 37 | path: artifacts/Logs 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /tools/ 2 | 3 | ## Ignore Visual Studio temporary files, build results, and 4 | ## files generated by popular Visual Studio add-ons. 5 | ## 6 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 7 | 8 | # User-specific files 9 | *.rsuser 10 | *.suo 11 | *.user 12 | *.userosscache 13 | *.sln.docstates 14 | 15 | # User-specific files (MonoDevelop/Xamarin Studio) 16 | *.userprefs 17 | 18 | # Build results 19 | [Dd]ebug/ 20 | [Dd]ebugPublic/ 21 | [Rr]elease/ 22 | [Rr]eleases/ 23 | x64/ 24 | x86/ 25 | [Aa][Rr][Mm]/ 26 | [Aa][Rr][Mm]64/ 27 | bld/ 28 | [Bb]in/ 29 | [Oo]bj/ 30 | [Ll]og/ 31 | 32 | # Visual Studio 2015/2017 cache/options directory 33 | .vs/ 34 | # Uncomment if you have tasks that create the project's static files in wwwroot 35 | #wwwroot/ 36 | 37 | # Visual Studio 2017 auto generated files 38 | Generated\ Files/ 39 | 40 | # MSTest test Results 41 | [Tt]est[Rr]esult*/ 42 | [Bb]uild[Ll]og.* 43 | 44 | # NUNIT 45 | *.VisualState.xml 46 | TestResult.xml 47 | 48 | # Build Results of an ATL Project 49 | [Dd]ebugPS/ 50 | [Rr]eleasePS/ 51 | dlldata.c 52 | 53 | # Benchmark Results 54 | BenchmarkDotNet.Artifacts/ 55 | 56 | # .NET Core 57 | project.lock.json 58 | project.fragment.lock.json 59 | artifacts/ 60 | 61 | # StyleCop 62 | StyleCopReport.xml 63 | 64 | # Files built by Visual Studio 65 | *_i.c 66 | *_p.c 67 | *_h.h 68 | *.ilk 69 | *.meta 70 | *.obj 71 | *.iobj 72 | *.pch 73 | *.pdb 74 | *.ipdb 75 | *.pgc 76 | *.pgd 77 | *.rsp 78 | *.sbr 79 | *.tlb 80 | *.tli 81 | *.tlh 82 | *.tmp 83 | *.tmp_proj 84 | *_wpftmp.csproj 85 | *.log 86 | *.vspscc 87 | *.vssscc 88 | .builds 89 | *.pidb 90 | *.svclog 91 | *.scc 92 | 93 | # Chutzpah Test files 94 | _Chutzpah* 95 | 96 | # Visual C++ cache files 97 | ipch/ 98 | *.aps 99 | *.ncb 100 | *.opendb 101 | *.opensdf 102 | *.sdf 103 | *.cachefile 104 | *.VC.db 105 | *.VC.VC.opendb 106 | 107 | # Visual Studio profiler 108 | *.psess 109 | *.vsp 110 | *.vspx 111 | *.sap 112 | 113 | # Visual Studio Trace Files 114 | *.e2e 115 | 116 | # TFS 2012 Local Workspace 117 | $tf/ 118 | 119 | # Guidance Automation Toolkit 120 | *.gpState 121 | 122 | # ReSharper is a .NET coding add-in 123 | _ReSharper*/ 124 | *.[Rr]e[Ss]harper 125 | *.DotSettings.user 126 | 127 | # JustCode is a .NET coding add-in 128 | .JustCode 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # The packages folder can be ignored because of Package Restore 188 | **/[Pp]ackages/* 189 | # except build/, which is used as an MSBuild target. 190 | !**/[Pp]ackages/build/ 191 | # Uncomment if necessary however generally it will be regenerated when needed 192 | #!**/[Pp]ackages/repositories.config 193 | # NuGet v3's project.json files produces more ignorable files 194 | *.nuget.props 195 | *.nuget.targets 196 | 197 | # Microsoft Azure Build Output 198 | csx/ 199 | *.build.csdef 200 | 201 | # Microsoft Azure Emulator 202 | ecf/ 203 | rcf/ 204 | 205 | # Windows Store app package directories and files 206 | AppPackages/ 207 | BundleArtifacts/ 208 | Package.StoreAssociation.xml 209 | _pkginfo.txt 210 | *.appx 211 | 212 | # Visual Studio cache files 213 | # files ending in .cache can be ignored 214 | *.[Cc]ache 215 | # but keep track of directories ending in .cache 216 | !?*.[Cc]ache/ 217 | 218 | # Others 219 | ClientBin/ 220 | ~$* 221 | *~ 222 | *.dbmdl 223 | *.dbproj.schemaview 224 | *.jfm 225 | *.pfx 226 | *.publishsettings 227 | orleans.codegen.cs 228 | 229 | # Including strong name files can present a security risk 230 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 231 | #*.snk 232 | 233 | # Since there are multiple workflows, uncomment next line to ignore bower_components 234 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 235 | #bower_components/ 236 | 237 | # RIA/Silverlight projects 238 | Generated_Code/ 239 | 240 | # Backup & report files from converting an old project file 241 | # to a newer Visual Studio version. Backup files are not needed, 242 | # because we have git ;-) 243 | _UpgradeReport_Files/ 244 | Backup*/ 245 | UpgradeLog*.XML 246 | UpgradeLog*.htm 247 | ServiceFabricBackup/ 248 | *.rptproj.bak 249 | 250 | # SQL Server files 251 | *.mdf 252 | *.ldf 253 | *.ndf 254 | 255 | # Business Intelligence projects 256 | *.rdl.data 257 | *.bim.layout 258 | *.bim_*.settings 259 | *.rptproj.rsuser 260 | *- Backup*.rdl 261 | 262 | # Microsoft Fakes 263 | FakesAssemblies/ 264 | 265 | # GhostDoc plugin setting file 266 | *.GhostDoc.xml 267 | 268 | # Node.js Tools for Visual Studio 269 | .ntvs_analysis.dat 270 | node_modules/ 271 | 272 | # Visual Studio 6 build log 273 | *.plg 274 | 275 | # Visual Studio 6 workspace options file 276 | *.opt 277 | 278 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 279 | *.vbw 280 | 281 | # Visual Studio LightSwitch build output 282 | **/*.HTMLClient/GeneratedArtifacts 283 | **/*.DesktopClient/GeneratedArtifacts 284 | **/*.DesktopClient/ModelManifest.xml 285 | **/*.Server/GeneratedArtifacts 286 | **/*.Server/ModelManifest.xml 287 | _Pvt_Extensions 288 | 289 | # Paket dependency manager 290 | .paket/paket.exe 291 | paket-files/ 292 | 293 | # FAKE - F# Make 294 | .fake/ 295 | 296 | # JetBrains Rider 297 | .idea/ 298 | *.sln.iml 299 | 300 | # CodeRush personal settings 301 | .cr/personal 302 | 303 | # Python Tools for Visual Studio (PTVS) 304 | __pycache__/ 305 | *.pyc 306 | 307 | # Cake - Uncomment if you are using it 308 | # tools/** 309 | # !tools/packages.config 310 | 311 | # Tabs Studio 312 | *.tss 313 | 314 | # Telerik's JustMock configuration file 315 | *.jmconfig 316 | 317 | # BizTalk build output 318 | *.btp.cs 319 | *.btm.cs 320 | *.odx.cs 321 | *.xsd.cs 322 | 323 | # OpenCover UI analysis results 324 | OpenCover/ 325 | 326 | # Azure Stream Analytics local run output 327 | ASALocalRun/ 328 | 329 | # MSBuild Binary and Structured Log 330 | *.binlog 331 | 332 | # NVidia Nsight GPU debugger configuration file 333 | *.nvuser 334 | 335 | # MFractors (Xamarin productivity tool) working folder 336 | .mfractor/ 337 | 338 | # Local History for Visual Studio 339 | .localhistory/ 340 | 341 | # BeatPulse healthcheck temp database 342 | healthchecksdb 343 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020–2021 Technology Solutions Associates, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Techsola.InstantReplay [![MyGet badge](https://img.shields.io/myget/techsola/vpre/Techsola.InstantReplay.svg?label=myget)](https://www.myget.org/feed/techsola/package/nuget/Techsola.InstantReplay "MyGet (prereleases)") [![Build status badge](https://github.com/Techsola/InstantReplay/workflows/CI/badge.svg)](https://github.com/Techsola/InstantReplay/actions?query=workflow%3ACI "Build status") 2 | 3 | [🔬 Currently experimental. More documentation will be added later.] 4 | 5 | Produces an animated GIF on demand of the last ten seconds of a Windows desktop app’s user interface. This can be useful to include in error reports or to help understand how an unusual situation came about. 6 | 7 | ### Goals 8 | 9 | - **Low resource usage** while recording 10 | 11 | - **Privacy**: never captures content from other apps 12 | 13 | - **Ease of consumption**: the right thing happens if you double-click a .gif file on Windows or if you open a .gif attachment in a web browser 14 | 15 | - **Fast generation** when a GIF is requested 16 | 17 | ### Non-goals 18 | 19 | - Optimizing GIF **file size** (unless it also speeds up GIF creation) 20 | 21 | - Pixel-perfect recording of **non-client** areas of the app windows (but improvements will be considered) 22 | 23 | ## Is this for me? 24 | 25 | While other integrations could happen in the future, right now this library only works with Windows desktop applications that have access to native Win32 APIs. 26 | 27 | | App model | Supported | 28 | |---------------|-----------| 29 | | Windows Forms | ✔ | 30 | | WPF | ✔ | 31 | | UWP | ❌ | 32 | 33 | To continue fleshing out the list: support currently depends on whether the app is able to [invoke](https://docs.microsoft.com/en-us/dotnet/standard/native-interop/pinvoke) native Windows functions such as [`BitBlt`](https://docs.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-bitblt) and [`EnumWindows`](https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-enumwindows). 34 | 35 | ## How to use 36 | 37 | [🔬 Currently experimental. Examples and more documentation will be added later.] 38 | 39 | ### Set up 40 | 41 | Until this library is released to nuget.org, add this package source to a `nuget.config` file at the root of your project’s source repository: 42 | 43 | ```xml 44 | 45 | 46 | 47 | 48 | 49 | 50 | ``` 51 | 52 | If your project was open in Visual Studio when you edited the `nuget.config` file, close the solution and reopen it. Then use the NuGet package manager to install the latest available version of the package `Techsola.InstantReplay` into your app’s startup project. Make sure that the ‘Include prerelease’ box is checked and the selected package source is ‘All.’ 53 | 54 | Now that the package is added to your project, add a call to `InstantReplayCamera.Start();` before your app’s first window is shown. (The namespace to include is `Techsola.InstantReplay`.) This call only needs to be made once in the lifetime of the process. Subsequent calls are ignored. 55 | 56 | For a Windows Forms app, the ideal place for this call is in `Program.Main` before `Application.Run` is called. 57 | 58 | ### Profit 59 | 60 | Whenever you want a GIF of the last ten seconds of the app’s user interface, call `InstantReplayCamera.SaveGif();` to obtain a byte array containing an animated GIF. (Or `null`, if there are currently no frames to save.) A good place to do this is in your app’s top-level unhandled exception reporter so that you get a recording of the UI along with the exception information. 61 | 62 | ℹ Consider calling `InstantReplayCamera.SaveGif` on a non-UI thread using `Task.Run` due to the CPU-blocking work it takes to encode a GIF. This way the user interface doesn't pause for even a split second. 63 | 64 | ## Debugging into Techsola.InstantReplay source 65 | 66 | Stepping into Techsola.InstantReplay source code, pausing the debugger while execution is inside Techsola.InstantReplay code and seeing the source, and setting breakpoints in Techsola.InstantReplay all require loading symbols for Techsola.InstantReplay. To do this in Visual Studio: 67 | 68 | 1. Go to Debug > Options, and uncheck ‘Enable Just My Code.’ (It’s a good idea to reenable this as soon as you’re finished with the task that requires debugging into a specific external library.) 69 | ℹ *Before* doing this, because Visual Studio can become unresponsive when attempting to load symbols for absolutely everything, I recommend going to Debugging > Symbols within the Options window and selecting ‘Load only specified modules.’ 70 | 71 | 2. If you are using a prerelease version of Techsola.InstantReplay package, go to Debugging > Symbols within the Options window and add this as a new symbol location: `https://www.myget.org/F/techsola/api/v2/symbolpackage/` 72 | If you are using a version that was released to nuget.org, enable the built-in ‘NuGet.org Symbol Server’ symbol location. 73 | 74 | 3. If ‘Load only specified modules’ is selected in Options > Debugging > Symbols, you will have to explicitly tell Visual Studio to load symbols for Techsola.InstantReplay. One way to do this while debugging is to go to Debug > Windows > Modules and right-click on Techsola.InstantReplay. Select ‘Load Symbols’ if you only want to do it for the current debugging session. Select ‘Always Load Automatically’ if you want to load symbols now and also add the file name to a list so that Visual Studio loads Techsola.InstantReplay symbols in all future debug sessions when Just My Code is disabled. 75 | -------------------------------------------------------------------------------- /Techsola.InstantReplay.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30709.64 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Techsola.InstantReplay", "src\Techsola.InstantReplay\Techsola.InstantReplay.csproj", "{BA395A92-F243-451B-BF8B-122F13E355AC}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestWinFormsApp", "src\TestWinFormsApp\TestWinFormsApp.csproj", "{A773CBD9-5495-4132-A6B6-95EE331564E6}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C380061D-E25A-4415-B5A4-7CFC98C536EC}" 11 | ProjectSection(SolutionItems) = preProject 12 | .editorconfig = .editorconfig 13 | src\Directory.Build.props = src\Directory.Build.props 14 | EndProjectSection 15 | EndProject 16 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Techsola.InstantReplay.Tests", "src\Techsola.InstantReplay.Tests\Techsola.InstantReplay.Tests.csproj", "{8AEB15F7-0FCC-48C5-B9B8-41177DFAD6D9}" 17 | EndProject 18 | Global 19 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 20 | Debug|Any CPU = Debug|Any CPU 21 | Release|Any CPU = Release|Any CPU 22 | EndGlobalSection 23 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 24 | {BA395A92-F243-451B-BF8B-122F13E355AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {BA395A92-F243-451B-BF8B-122F13E355AC}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {BA395A92-F243-451B-BF8B-122F13E355AC}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {BA395A92-F243-451B-BF8B-122F13E355AC}.Release|Any CPU.Build.0 = Release|Any CPU 28 | {A773CBD9-5495-4132-A6B6-95EE331564E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {A773CBD9-5495-4132-A6B6-95EE331564E6}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {A773CBD9-5495-4132-A6B6-95EE331564E6}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {A773CBD9-5495-4132-A6B6-95EE331564E6}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {8AEB15F7-0FCC-48C5-B9B8-41177DFAD6D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {8AEB15F7-0FCC-48C5-B9B8-41177DFAD6D9}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {8AEB15F7-0FCC-48C5-B9B8-41177DFAD6D9}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {8AEB15F7-0FCC-48C5-B9B8-41177DFAD6D9}.Release|Any CPU.Build.0 = Release|Any CPU 36 | EndGlobalSection 37 | GlobalSection(SolutionProperties) = preSolution 38 | HideSolutionNode = FALSE 39 | EndGlobalSection 40 | GlobalSection(ExtensibilityGlobals) = postSolution 41 | SolutionGuid = {E57876F9-9637-4618-9789-AAEDE0BC1345} 42 | EndGlobalSection 43 | EndGlobal 44 | -------------------------------------------------------------------------------- /Techsola.InstantReplay.sln.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | WARNING 3 | WARNING 4 | True 5 | DC 6 | True 7 | True 8 | True 9 | True 10 | True 11 | True -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | Param( 2 | [switch] $Release, 3 | [string] $SigningCertThumbprint, 4 | [string] $TimestampServer 5 | ) 6 | 7 | $ErrorActionPreference = 'Stop' 8 | 9 | # Options 10 | $configuration = 'Release' 11 | $artifactsDir = Join-Path (Resolve-Path .) 'artifacts' 12 | $packagesDir = Join-Path $artifactsDir 'Packages' 13 | $testResultsDir = Join-Path $artifactsDir 'Test results' 14 | $logsDir = Join-Path $artifactsDir 'Logs' 15 | 16 | # Detection 17 | . $PSScriptRoot\build\Get-DetectedCiVersion.ps1 18 | $versionInfo = Get-DetectedCiVersion -Release:$Release 19 | Update-CiServerBuildName $versionInfo.ProductVersion 20 | Write-Host "Building using version $($versionInfo.ProductVersion)" 21 | 22 | $dotnetArgs = @( 23 | '--configuration', $configuration 24 | '/p:RepositoryCommit=' + $versionInfo.CommitHash 25 | '/p:Version=' + $versionInfo.ProductVersion 26 | '/p:PackageVersion=' + $versionInfo.PackageVersion 27 | '/p:FileVersion=' + $versionInfo.FileVersion 28 | '/p:ContinuousIntegrationBuild=' + ($env:CI -or $env:TF_BUILD) 29 | ) 30 | 31 | # Build 32 | dotnet build /bl:$logsDir\build.binlog @dotnetArgs 33 | if ($LastExitCode) { exit 1 } 34 | 35 | if ($SigningCertThumbprint) { 36 | . build\SignTool.ps1 37 | SignTool $SigningCertThumbprint $TimestampServer ( 38 | Get-ChildItem src\Techsola.InstantReplay\bin\$configuration -Recurse -Include Techsola.InstantReplay.dll) 39 | } 40 | 41 | # Pack 42 | Remove-Item -Recurse -Force $packagesDir -ErrorAction Ignore 43 | 44 | dotnet pack src\Techsola.InstantReplay --no-build --output $packagesDir /bl:$logsDir\pack.binlog @dotnetArgs 45 | if ($LastExitCode) { exit 1 } 46 | 47 | if ($SigningCertThumbprint) { 48 | # Waiting for 'dotnet sign' to become available (https://github.com/NuGet/Home/issues/7939) 49 | $nuget = 'tools\nuget.exe' 50 | if (-not (Test-Path $nuget)) { 51 | New-Item -ItemType Directory -Force -Path tools 52 | 53 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 54 | Invoke-WebRequest -Uri https://dist.nuget.org/win-x86-commandline/latest/nuget.exe -OutFile $nuget 55 | } 56 | 57 | # Workaround for https://github.com/NuGet/Home/issues/10446 58 | foreach ($extension in 'nupkg', 'snupkg') { 59 | & $nuget sign $packagesDir\*.$extension -CertificateFingerprint $SigningCertThumbprint -Timestamper $TimestampServer 60 | } 61 | } 62 | 63 | # Test 64 | Remove-Item -Recurse -Force $testResultsDir -ErrorAction Ignore 65 | 66 | dotnet test --no-build --configuration $configuration --logger trx --results-directory $testResultsDir /bl:"$logsDir\test.binlog" 67 | if ($LastExitCode) { exit 1 } 68 | -------------------------------------------------------------------------------- /build/CiServerIntegration.ps1: -------------------------------------------------------------------------------- 1 | class BuildMetadata { 2 | [int] $BuildNumber 3 | [System.Nullable[int]] $PullRequestNumber 4 | [string] $BranchName 5 | } 6 | 7 | function Get-BuildMetadata { 8 | $metadata = [BuildMetadata]::new() 9 | 10 | if ($env:TF_BUILD) { 11 | $metadata.BuildNumber = $env:Build_BuildId 12 | $metadata.PullRequestNumber = $env:System_PullRequest_PullRequestNumber 13 | $metadata.BranchName = $env:Build_SourceBranchName 14 | } 15 | elseif ($env:GITHUB_ACTIONS) { 16 | $metadata.BuildNumber = $env:GITHUB_RUN_NUMBER 17 | 18 | if ($env:GITHUB_REF.StartsWith('refs/pull/')) { 19 | $trimmedRef = $env:GITHUB_REF.Substring('refs/pull/'.Length) 20 | $metadata.PullRequestNumber = $trimmedRef.Substring(0, $trimmedRef.IndexOf('/')) 21 | $metadata.BranchName = $env:GITHUB_BASE_REF 22 | } elseif ($env:GITHUB_REF.StartsWith('refs/heads/')) { 23 | $metadata.BranchName = $env:GITHUB_REF.Substring('refs/heads/'.Length) 24 | } 25 | } 26 | elseif ($env:CI) { 27 | throw 'Build metadata detection is not implemented for this CI server.' 28 | } 29 | 30 | return $metadata 31 | } 32 | 33 | function Update-CiServerBuildName([Parameter(Mandatory=$true)] [string] $BuildName) { 34 | if ($env:TF_BUILD) { 35 | Write-Output "##vso[build.updatebuildnumber]$BuildName" 36 | } 37 | elseif ($env:GITHUB_ACTIONS) { 38 | # GitHub Actions does not appear to have a way to dynamically update the name/number of a workflow run. 39 | } 40 | elseif ($env:CI) { 41 | throw 'Build name updating is not implemented for this CI server.' 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /build/Get-DetectedCiVersion.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\CiServerIntegration.ps1 2 | 3 | function Get-VersionPrefixFromTags { 4 | function Get-VersionPrefix([Parameter(Mandatory=$true)] [string] $Tag) { 5 | # Start the search at index 6, skipping 1 for the `v` and 5 because no valid semantic version can have a suffix sooner than `N.N.N`. 6 | $suffixStart = $Tag.IndexOfAny(('-', '+'), 6) 7 | 8 | return [version] $( 9 | if ($suffixStart -eq -1) { 10 | $Tag.Substring(1) 11 | } else { 12 | $Tag.Substring(1, $suffixStart - 1) 13 | }) 14 | } 15 | 16 | $currentTags = @(git tag --list v* --points-at head --sort=-v:refname) 17 | if ($currentTags.Count -gt 0) { 18 | # Head is tagged, so the tag is the intended CI version for this build. 19 | return Get-VersionPrefix $currentTags[0] 20 | } 21 | 22 | $previousTags = @(git tag --list v* --sort=-v:refname) 23 | if ($previousTags.Count -gt 0) { 24 | # Head is not tagged, so it would be greater than the most recent tagged version. 25 | $previousVersion = Get-VersionPrefix $previousTags[0] 26 | return [version]::new($previousVersion.Major, $previousVersion.Minor, $previousVersion.Build + 1) 27 | } 28 | 29 | # No release has been tagged, so the initial version should be whatever the source files currently contain. 30 | } 31 | 32 | function XmlPeek( 33 | [Parameter(Mandatory=$true)] [string] $FilePath, 34 | [Parameter(Mandatory=$true)] [string] $XPath, 35 | [HashTable] $NamespaceUrisByPrefix 36 | ) { 37 | $document = [xml](Get-Content $FilePath) 38 | $namespaceManager = [System.Xml.XmlNamespaceManager]::new($document.NameTable) 39 | 40 | if ($null -ne $NamespaceUrisByPrefix) { 41 | foreach ($prefix in $NamespaceUrisByPrefix.Keys) { 42 | $namespaceManager.AddNamespace($prefix, $NamespaceUrisByPrefix[$prefix]); 43 | } 44 | } 45 | 46 | return $document.SelectSingleNode($XPath, $namespaceManager).Value 47 | } 48 | 49 | class VersionInfo { 50 | [string] $CommitHash 51 | [string] $ProductVersion 52 | [string] $PackageVersion 53 | [string] $FileVersion 54 | } 55 | 56 | function Get-DetectedCiVersion([switch] $Release) { 57 | $versionPrefix = [version](XmlPeek 'src\Techsola.InstantReplay\Techsola.InstantReplay.csproj' '/Project/PropertyGroup/Version/text()') 58 | $minVersionPrefix = Get-VersionPrefixFromTags 59 | if ($versionPrefix -lt $minVersionPrefix) { $versionPrefix = $minVersionPrefix } 60 | 61 | $buildMetadata = Get-BuildMetadata 62 | $buildNumber = $buildMetadata.BuildNumber 63 | 64 | $versionInfo = [VersionInfo]::new() 65 | $versionInfo.CommitHash = (git rev-parse head) 66 | $versionInfo.ProductVersion = $versionPrefix 67 | $versionInfo.PackageVersion = $versionPrefix 68 | $versionInfo.FileVersion = $versionPrefix 69 | 70 | if (!$buildNumber) { 71 | if ($Release) { throw 'Cannot release without a build number.' } 72 | } 73 | else { 74 | $shortCommitHash = (git rev-parse --short=8 head) 75 | 76 | if ($Release) { 77 | $versionInfo.ProductVersion += "+build.$buildNumber.commit.$shortCommitHash" 78 | } 79 | elseif ($buildMetadata.PullRequestNumber) { 80 | $versionInfo.ProductVersion += "-$buildNumber.pr.$($buildMetadata.PullRequestNumber)" 81 | $versionInfo.PackageVersion += "-$buildNumber.pr.$($buildMetadata.PullRequestNumber)" 82 | } 83 | elseif ($buildMetadata.BranchName -ne 'main') { 84 | $prereleaseSegment = $buildMetadata.BranchName -replace '[^a-zA-Z0-9]+', '-' 85 | 86 | $versionInfo.ProductVersion += "-$buildNumber.$prereleaseSegment" 87 | $versionInfo.PackageVersion += "-$buildNumber.$prereleaseSegment" 88 | } 89 | else { 90 | $versionInfo.ProductVersion += "-ci.$buildNumber+commit.$shortCommitHash" 91 | $versionInfo.PackageVersion += "-ci.$buildNumber" 92 | } 93 | 94 | $versionInfo.FileVersion += ".$buildNumber" 95 | } 96 | 97 | return $versionInfo 98 | } 99 | -------------------------------------------------------------------------------- /build/SignTool.ps1: -------------------------------------------------------------------------------- 1 | function Find-SignTool { 2 | $sdk = Get-ChildItem 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Microsoft SDKs\Windows' | 3 | ForEach-Object { Get-ItemProperty $_.PSPath } | 4 | Where-Object InstallationFolder -ne $null | 5 | Sort-Object { [version]$_.ProductVersion } | 6 | Select-Object -Last 1 7 | 8 | if (!$sdk) { throw 'Cannot find a Windows SDK installation that has signtool.exe.' } 9 | 10 | $version = [version]$sdk.ProductVersion; 11 | $major = $version.Major; 12 | $minor = [Math]::Max($version.Minor, 0); 13 | $build = [Math]::Max($version.Build, 0); 14 | $revision = [Math]::Max($version.Revision, 0); 15 | 16 | return Join-Path $sdk.InstallationFolder "bin\$major.$minor.$build.$revision\x64\signtool.exe" 17 | } 18 | 19 | function SignTool( 20 | [Parameter(Mandatory=$true)] [string] $CertificateThumbprint, 21 | [Parameter(Mandatory=$true)] [string] $TimestampServer, 22 | [Parameter(Mandatory=$true)] [string[]] $Files 23 | ) { 24 | & (Find-SignTool) sign /sha1 $CertificateThumbprint /fd SHA256 /tr $TimestampServer @Files 25 | if ($LastExitCode) { exit 1 } 26 | } 27 | -------------------------------------------------------------------------------- /src/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 5 | enable 6 | 8 7 | true 8 | RA1000 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay.Tests/InstantReplayCameraTests.cs: -------------------------------------------------------------------------------- 1 | using NUnit.Framework; 2 | using Shouldly; 3 | 4 | namespace Techsola.InstantReplay.Tests 5 | { 6 | public static class InstantReplayCameraTests 7 | { 8 | [Test] 9 | public static void SaveGif_with_no_frames_collected_should_return_null() 10 | { 11 | InstantReplayCamera.SaveGif().ShouldBeNull(); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay.Tests/NativeTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Reflection; 4 | using System.Runtime.InteropServices; 5 | using NUnit.Framework; 6 | using Shouldly; 7 | 8 | namespace Techsola.InstantReplay.Tests 9 | { 10 | public static class NativeTests 11 | { 12 | public static IEnumerable NativeAPIMethods => 13 | from t in typeof(InstantReplayCamera).Assembly.GetTypes() 14 | from m in t.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) 15 | where m.IsDefined(typeof(DllImportAttribute), inherit: false) 16 | select new TestCaseData(m).SetArgDisplayNames($"{t.Name}.{m.Name}"); 17 | 18 | [TestCaseSource(nameof(NativeAPIMethods))] 19 | public static void Native_APIs_are_annotated_with_required_OS_version(MethodInfo apiMethod) 20 | { 21 | var attribute = CustomAttributeData.GetCustomAttributes(apiMethod) 22 | .Where(a => a.Constructor?.DeclaringType?.FullName == "System.Runtime.Versioning.SupportedOSPlatformAttribute") 23 | .ShouldHaveSingleItem(); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay.Tests/PolyfillTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using NUnit.Framework; 6 | using Shouldly; 7 | 8 | namespace Techsola.InstantReplay.Tests 9 | { 10 | public static class PolyfillTests 11 | { 12 | public static IEnumerable PolyfillTypes => 13 | from t in typeof(InstantReplayCamera).Assembly.GetTypes() 14 | where t.Namespace?.StartsWith("System", StringComparison.Ordinal) ?? false 15 | select t; 16 | 17 | [TestCaseSource(nameof(PolyfillTypes))] 18 | public static void Polyfill_types_are_not_exposed(Type polyfillType) 19 | { 20 | (polyfillType.Attributes & TypeAttributes.VisibilityMask).ShouldBe(TypeAttributes.NotPublic); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay.Tests/Techsola.InstantReplay.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net35;net48;net8.0-windows 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Techsola/InstantReplay/d25df1f33704193dcd9b7a2c77eac6a7541e13ab/src/Techsola.InstantReplay.snk -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/AnimatedCursorRenderer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Runtime.InteropServices; 5 | using Techsola.InstantReplay.Native; 6 | using Windows.Win32; 7 | using Windows.Win32.Graphics.Gdi; 8 | using Windows.Win32.UI.WindowsAndMessaging; 9 | 10 | namespace Techsola.InstantReplay 11 | { 12 | internal sealed class AnimatedCursorRenderer 13 | { 14 | private readonly Dictionary cursorInfoByHandle = new(); 15 | private readonly Dictionary cursorAnimationStepByHandle = new(); 16 | 17 | public void Render(DeleteDCSafeHandle deviceContext, HCURSOR cursorHandle, int cursorX, int cursorY, out UInt16Rectangle changedArea) 18 | { 19 | if (!cursorInfoByHandle.TryGetValue(cursorHandle, out var cursorInfo)) 20 | { 21 | // Workaround for https://github.com/microsoft/CsWin32/issues/256 22 | // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 23 | if (!PInvoke.GetIconInfo(new UnownedHandle(cursorHandle), out var iconInfo)) throw new Win32Exception(); 24 | new DeleteObjectSafeHandle(iconInfo.hbmColor).Dispose(); 25 | 26 | using var bitmapHandle = new DeleteObjectSafeHandle(iconInfo.hbmMask); 27 | 28 | var bitmap = default(BITMAP); 29 | unsafe 30 | { 31 | // Workaround for https://github.com/microsoft/CsWin32/issues/275 32 | // ↓↓↓↓↓↓↓↓↓ ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 33 | var bytesCopied = PInvoke.GetObject((HGDIOBJ)bitmapHandle.DangerousGetHandle(), Marshal.SizeOf(typeof(BITMAP)), &bitmap); 34 | if (bytesCopied != Marshal.SizeOf(typeof(BITMAP))) 35 | throw new Win32Exception("GetObject returned an unexpected number of bytes."); 36 | } 37 | 38 | cursorInfo = ((iconInfo.xHotspot, iconInfo.yHotspot), ((uint)bitmap.bmWidth, (uint)bitmap.bmHeight)); 39 | cursorInfoByHandle.Add(cursorHandle, cursorInfo); 40 | } 41 | 42 | if (!cursorAnimationStepByHandle.TryGetValue(cursorHandle, out var cursorAnimationStep)) 43 | cursorAnimationStep = (Current: 0, Max: uint.MaxValue); 44 | 45 | var deviceContextNeedsRelease = false; 46 | deviceContext.DangerousAddRef(ref deviceContextNeedsRelease); 47 | try 48 | { 49 | while (!PInvoke.DrawIconEx( 50 | (HDC)deviceContext.DangerousGetHandle(), 51 | cursorX - (int)cursorInfo.Hotspot.X, 52 | cursorY - (int)cursorInfo.Hotspot.Y, 53 | /* Workaround for https://github.com/microsoft/CsWin32/issues/256 54 | ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ */ 55 | new UnownedHandle(cursorHandle), 56 | cxWidth: 0, 57 | cyWidth: 0, 58 | cursorAnimationStep.Current, 59 | hbrFlickerFreeDraw: null, 60 | DI_FLAGS.DI_NORMAL)) 61 | { 62 | var lastError = Marshal.GetLastWin32Error(); 63 | 64 | if ((ERROR)lastError == ERROR.INVALID_PARAMETER && cursorAnimationStep.Current > 0) 65 | { 66 | cursorAnimationStep = (Current: 0, Max: cursorAnimationStep.Current - 1); 67 | continue; 68 | } 69 | 70 | throw new Win32Exception(lastError); 71 | } 72 | } 73 | finally 74 | { 75 | if (deviceContextNeedsRelease) deviceContext.DangerousRelease(); 76 | } 77 | 78 | cursorAnimationStep.Current = cursorAnimationStep.Current == cursorAnimationStep.Max ? 0 : cursorAnimationStep.Current + 1; 79 | cursorAnimationStepByHandle[cursorHandle] = cursorAnimationStep; 80 | 81 | changedArea = new( 82 | (ushort)Math.Max(0, cursorX - (int)cursorInfo.Hotspot.X), 83 | (ushort)Math.Max(0, cursorY - (int)cursorInfo.Hotspot.Y), 84 | (ushort)cursorInfo.Size.Width, 85 | (ushort)cursorInfo.Size.Height); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/BasicCompletionSource.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | #if NET35 4 | using System.Threading; 5 | #else 6 | using System.Threading.Tasks; 7 | #endif 8 | 9 | namespace Techsola.InstantReplay 10 | { 11 | /// 12 | /// Because TaskCompletionSource isn't available on .NET Framework 3.5. 13 | /// 14 | internal sealed class BasicCompletionSource : IDisposable 15 | { 16 | #if NET35 17 | private readonly ManualResetEvent completed = new(initialState: false); 18 | private Exception? exception; 19 | private T? result; 20 | #else 21 | private readonly TaskCompletionSource source = new(); 22 | #endif 23 | 24 | public void SetResult(T result) 25 | { 26 | #if NET35 27 | if (completed.WaitOne(TimeSpan.Zero)) 28 | throw new InvalidOperationException("The source is already completed."); 29 | 30 | this.result = result; 31 | completed.Set(); 32 | #else 33 | source.SetResult(result); 34 | #endif 35 | } 36 | 37 | public void SetException(Exception exception) 38 | { 39 | if (exception is null) throw new ArgumentNullException(nameof(exception)); 40 | 41 | #if NET35 42 | if (completed.WaitOne(TimeSpan.Zero)) 43 | throw new InvalidOperationException("The source is already completed."); 44 | 45 | this.exception = exception; 46 | completed.Set(); 47 | #else 48 | source.SetException(exception); 49 | #endif 50 | } 51 | 52 | public bool SetExceptionAndReturnFalse(Exception exception) 53 | { 54 | SetException(exception); 55 | return false; 56 | } 57 | 58 | public T GetResult() 59 | { 60 | #if NET35 61 | completed.WaitOne(); 62 | 63 | if (exception is not null) throw exception; 64 | return result!; 65 | #else 66 | return source.Task.GetAwaiter().GetResult(); 67 | #endif 68 | } 69 | 70 | public void Dispose() 71 | { 72 | #if NET35 73 | completed.Close(); 74 | #endif 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/CircularBuffer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Techsola.InstantReplay 4 | { 5 | internal sealed class CircularBuffer 6 | { 7 | private readonly T?[] array; 8 | private int nextIndex; 9 | private bool didWrap; 10 | 11 | public CircularBuffer(int capacity) 12 | { 13 | array = new T?[capacity]; 14 | } 15 | 16 | public int Capacity => array.Length; 17 | 18 | public int Count => didWrap ? array.Length : nextIndex; 19 | 20 | public ref T? GetNextRef() 21 | { 22 | ref var itemRef = ref array[nextIndex]; 23 | 24 | nextIndex++; 25 | if (nextIndex == array.Length) 26 | { 27 | nextIndex = 0; 28 | didWrap = true; 29 | } 30 | 31 | return ref itemRef; 32 | } 33 | 34 | public void Add(T value) => GetNextRef() = value; 35 | 36 | public T?[] GetRawBuffer() => array; 37 | 38 | public T[] ToArray() 39 | { 40 | if (didWrap) 41 | { 42 | var snapshot = new T[array.Length]; 43 | 44 | var oldestSideLength = array.Length - nextIndex; 45 | Array.Copy(array, nextIndex, snapshot, 0, oldestSideLength); 46 | Array.Copy(array, 0, snapshot, oldestSideLength, nextIndex); 47 | 48 | return snapshot; 49 | } 50 | else 51 | { 52 | var snapshot = new T[nextIndex]; 53 | 54 | Array.Copy(array, 0, snapshot, 0, nextIndex); 55 | 56 | return snapshot; 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/Color.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | 3 | namespace Techsola.InstantReplay 4 | { 5 | [StructLayout(LayoutKind.Sequential)] 6 | internal struct Color 7 | { 8 | public byte Channel3, Channel2, Channel1; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/ColorEnumerable.cs: -------------------------------------------------------------------------------- 1 | namespace Techsola.InstantReplay 2 | { 3 | internal readonly ref struct ColorEnumerable 4 | { 5 | private readonly unsafe byte* start; 6 | private readonly uint width; 7 | private readonly uint stride; 8 | private readonly uint height; 9 | 10 | public unsafe ColorEnumerable(byte* start, uint width, uint stride, uint height) 11 | { 12 | this.start = start; 13 | this.width = width; 14 | this.stride = stride; 15 | this.height = height; 16 | } 17 | 18 | public ColorEnumerator GetEnumerator() 19 | { 20 | unsafe 21 | { 22 | return new(start, width, stride, height); 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/ColorEnumerator.cs: -------------------------------------------------------------------------------- 1 | namespace Techsola.InstantReplay 2 | { 3 | internal ref struct ColorEnumerator 4 | { 5 | private unsafe byte* next; 6 | private unsafe byte* lineEnd; 7 | private readonly unsafe byte* imageEnd; 8 | private readonly uint stride; 9 | private readonly uint strideSkip; 10 | 11 | public unsafe ColorEnumerator(byte* start, uint width, uint stride, uint height) 12 | { 13 | next = start - 3; 14 | lineEnd = start + (width * 3); 15 | this.stride = stride; 16 | strideSkip = stride - (width * 3); 17 | imageEnd = start + (height * stride) - strideSkip; 18 | } 19 | 20 | public Color Current 21 | { 22 | get 23 | { 24 | unsafe 25 | { 26 | return *(Color*)next; 27 | } 28 | } 29 | } 30 | 31 | public bool MoveNext() 32 | { 33 | unsafe 34 | { 35 | next += 3; 36 | if (next >= lineEnd) 37 | { 38 | if (next >= imageEnd) return false; 39 | next += strideSkip; 40 | lineEnd += stride; 41 | } 42 | return true; 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/Composition.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Runtime.InteropServices; 3 | using Techsola.InstantReplay.Native; 4 | using Windows.Win32; 5 | using Windows.Win32.Graphics.Gdi; 6 | 7 | namespace Techsola.InstantReplay 8 | { 9 | internal readonly ref struct Composition 10 | { 11 | private readonly DeleteObjectSafeHandle bitmap; 12 | 13 | public byte BytesPerPixel { get; } 14 | public uint Stride { get; } 15 | public DeleteDCSafeHandle DeviceContext { get; } 16 | public unsafe byte* PixelDataPointer { get; } 17 | 18 | /// 19 | /// Call before accessing pixels after batchable GDI functions have been called. 20 | /// 21 | public ColorEnumerable EnumerateRange(UInt16Rectangle rectangle) 22 | { 23 | unsafe 24 | { 25 | return new( 26 | PixelDataPointer + (rectangle.Left * BytesPerPixel) + (rectangle.Top * Stride), 27 | rectangle.Width, 28 | Stride, 29 | rectangle.Height); 30 | } 31 | } 32 | 33 | public Composition(uint width, uint height, ushort bitsPerPixel) 34 | { 35 | BytesPerPixel = (byte)(bitsPerPixel >> 3); 36 | Stride = (((width * BytesPerPixel) + 3) / 4) * 4; 37 | 38 | DeviceContext = new DeleteDCSafeHandle(PInvoke.CreateCompatibleDC(default)).ThrowWithoutLastErrorAvailableIfInvalid(nameof(PInvoke.CreateCompatibleDC)); 39 | var deviceContextNeedsRelease = false; 40 | DeviceContext.DangerousAddRef(ref deviceContextNeedsRelease); 41 | try 42 | { 43 | unsafe 44 | { 45 | var bitmapInfo = new BITMAPINFO 46 | { 47 | bmiHeader = 48 | { 49 | biSize = (uint)Marshal.SizeOf(typeof(BITMAPINFOHEADER)), 50 | biWidth = (int)width, 51 | biHeight = -(int)height, 52 | biPlanes = 1, 53 | biBitCount = bitsPerPixel, 54 | }, 55 | }; 56 | 57 | bitmap = PInvoke.CreateDIBSection((HDC)DeviceContext.DangerousGetHandle(), &bitmapInfo, DIB_USAGE.DIB_RGB_COLORS, out var pointer, hSection: null, offset: 0).ThrowLastErrorIfInvalid(); 58 | 59 | PixelDataPointer = (byte*)pointer; 60 | } 61 | 62 | if (PInvoke.SelectObject((HDC)DeviceContext.DangerousGetHandle(), (HGDIOBJ)bitmap.DangerousGetHandle()).IsNull) 63 | throw new Win32Exception("SelectObject failed."); 64 | } 65 | finally 66 | { 67 | if (deviceContextNeedsRelease) DeviceContext.DangerousRelease(); 68 | } 69 | } 70 | 71 | public void Dispose() 72 | { 73 | bitmap.Dispose(); 74 | DeviceContext.Dispose(); 75 | } 76 | 77 | public void Clear(int x, int y, int width, int height, ref bool needsGdiFlush) 78 | { 79 | if (width <= 0 || height <= 0) return; 80 | 81 | var deviceContextNeedsRelease = false; 82 | DeviceContext.DangerousAddRef(ref deviceContextNeedsRelease); 83 | try 84 | { 85 | if (!PInvoke.BitBlt((HDC)DeviceContext.DangerousGetHandle(), x, y, width, height, hdcSrc: default, 0, 0, ROP_CODE.BLACKNESS)) 86 | { 87 | var lastError = Marshal.GetLastWin32Error(); 88 | if (lastError != 0) throw new Win32Exception(lastError); 89 | needsGdiFlush = true; 90 | } 91 | else 92 | { 93 | needsGdiFlush = false; 94 | } 95 | } 96 | finally 97 | { 98 | if (deviceContextNeedsRelease) DeviceContext.DangerousRelease(); 99 | } 100 | } 101 | } 102 | } 103 | 104 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/DiffBoundsDetector.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Techsola.InstantReplay 4 | { 5 | internal static class DiffBoundsDetector 6 | { 7 | // Consider vectorizing if it's worth it, but be careful not to read past the end of the last pixel. 8 | 9 | /// 10 | /// Assumes that and have the same number of bytes per pixel 11 | /// and that their strides are each a multiple of 4 bytes. 12 | /// 13 | public static void CropToChanges(Composition first, Composition second, ref UInt16Rectangle boundingRectangle) 14 | { 15 | CropTopToChanges(first, second, ref boundingRectangle); 16 | CropBottomToChanges(first, second, ref boundingRectangle); 17 | CropLeftToChanges(first, second, ref boundingRectangle); 18 | CropRightToChanges(first, second, ref boundingRectangle); 19 | } 20 | 21 | private static (uint StartIndex, uint Length) Get32BitColumnRange(uint left, uint width, byte bytesPerPixel) 22 | { 23 | var startIndex = left * bytesPerPixel / sizeof(uint); 24 | var endIndex = checked(unchecked((left + width) * bytesPerPixel) - 1) / sizeof(uint); 25 | 26 | return (startIndex, endIndex + 1 - startIndex); 27 | } 28 | 29 | private static void CropTopToChanges(Composition first, Composition second, ref UInt16Rectangle boundingRectangle) 30 | { 31 | if (boundingRectangle.Width == 0 || boundingRectangle.Height == 0) return; 32 | 33 | unsafe 34 | { 35 | var uintRange = Get32BitColumnRange(boundingRectangle.Left, boundingRectangle.Width, first.BytesPerPixel); 36 | var firstUintStride = first.Stride / sizeof(uint); 37 | var secondUintStride = second.Stride / sizeof(uint); 38 | 39 | for (var y = boundingRectangle.Top; y < boundingRectangle.Top + boundingRectangle.Height; y++) 40 | { 41 | var firstPointer = (uint*)first.PixelDataPointer + (y * firstUintStride) + uintRange.StartIndex; 42 | var secondPointer = (uint*)second.PixelDataPointer + (y * secondUintStride) + uintRange.StartIndex; 43 | var firstPointerExclusiveEnd = firstPointer + uintRange.Length; 44 | 45 | while (firstPointer < firstPointerExclusiveEnd) 46 | { 47 | if (*firstPointer != *secondPointer) 48 | { 49 | boundingRectangle.Height = (ushort)(boundingRectangle.Height + boundingRectangle.Top - y); 50 | boundingRectangle.Top = y; 51 | return; 52 | } 53 | 54 | firstPointer++; 55 | secondPointer++; 56 | } 57 | } 58 | } 59 | 60 | boundingRectangle = default; 61 | } 62 | 63 | /// 64 | /// Assumes that the top has already been cropped and therefore the top row contains changes. Also simplifies 65 | /// the unsigned bounds check. 66 | /// 67 | private static void CropBottomToChanges(Composition first, Composition second, ref UInt16Rectangle boundingRectangle) 68 | { 69 | if (boundingRectangle.Width == 0 || boundingRectangle.Height == 0) return; 70 | 71 | unsafe 72 | { 73 | var uintRange = Get32BitColumnRange(boundingRectangle.Left, boundingRectangle.Width, first.BytesPerPixel); 74 | var firstUintStride = first.Stride / sizeof(uint); 75 | var secondUintStride = second.Stride / sizeof(uint); 76 | 77 | for (var y = (ushort)(boundingRectangle.Top + boundingRectangle.Height - 1); y > boundingRectangle.Top; y--) 78 | { 79 | var firstPointer = (uint*)first.PixelDataPointer + (y * firstUintStride) + uintRange.StartIndex; 80 | var secondPointer = (uint*)second.PixelDataPointer + (y * secondUintStride) + uintRange.StartIndex; 81 | var firstPointerExclusiveEnd = firstPointer + uintRange.Length; 82 | 83 | while (firstPointer < firstPointerExclusiveEnd) 84 | { 85 | if (*firstPointer != *secondPointer) 86 | { 87 | boundingRectangle.Height = (ushort)(y - boundingRectangle.Top + 1); 88 | return; 89 | } 90 | 91 | firstPointer++; 92 | secondPointer++; 93 | } 94 | } 95 | } 96 | 97 | boundingRectangle.Height = 1; 98 | } 99 | 100 | private static void CropLeftToChanges(Composition first, Composition second, ref UInt16Rectangle boundingRectangle) 101 | { 102 | if (boundingRectangle.Width == 0 || boundingRectangle.Height == 0) return; 103 | 104 | unsafe 105 | { 106 | var uintRange = Get32BitColumnRange(boundingRectangle.Left, boundingRectangle.Width, first.BytesPerPixel); 107 | var firstUintStride = first.Stride / sizeof(uint); 108 | var secondUintStride = second.Stride / sizeof(uint); 109 | 110 | for (var uintIndex = uintRange.StartIndex; uintIndex < uintRange.StartIndex + uintRange.Length; uintIndex++) 111 | { 112 | var firstPointer = (uint*)first.PixelDataPointer + (boundingRectangle.Top * firstUintStride) + uintIndex; 113 | var secondPointer = (uint*)second.PixelDataPointer + (boundingRectangle.Top * secondUintStride) + uintIndex; 114 | var firstPointerExclusiveEnd = firstPointer + (boundingRectangle.Height * firstUintStride); 115 | 116 | while (firstPointer < firstPointerExclusiveEnd) 117 | { 118 | if (*firstPointer != *secondPointer) 119 | { 120 | // We don't know which changed of the two columns of pixels that this uint column is 121 | // overlapping, but it doesn't seem important enough to do a slower scan to find out. 122 | // Round to the left to be on the safe side. 123 | var x = uintIndex * sizeof(uint) / first.BytesPerPixel; 124 | 125 | if (boundingRectangle.Left < x) 126 | { 127 | boundingRectangle.Width = (ushort)(boundingRectangle.Width + boundingRectangle.Left - x); 128 | boundingRectangle.Left = (ushort)x; 129 | } 130 | return; 131 | } 132 | 133 | firstPointer += firstUintStride; 134 | secondPointer += secondUintStride; 135 | } 136 | } 137 | } 138 | 139 | boundingRectangle = default; 140 | } 141 | 142 | /// 143 | /// Assumes that the left has already been cropped and therefore the left row contains changes. Also simplifies 144 | /// the unsigned bounds check. 145 | /// 146 | private static void CropRightToChanges(Composition first, Composition second, ref UInt16Rectangle boundingRectangle) 147 | { 148 | if (boundingRectangle.Width == 0 || boundingRectangle.Height == 0) return; 149 | 150 | unsafe 151 | { 152 | var uintRange = Get32BitColumnRange(boundingRectangle.Left, boundingRectangle.Width, first.BytesPerPixel); 153 | var firstUintStride = first.Stride / sizeof(uint); 154 | var secondUintStride = second.Stride / sizeof(uint); 155 | 156 | for (var uintIndex = uintRange.StartIndex + uintRange.Length - 1; uintIndex > uintRange.StartIndex; uintIndex--) 157 | { 158 | var firstPointer = (uint*)first.PixelDataPointer + (boundingRectangle.Top * firstUintStride) + uintIndex; 159 | var secondPointer = (uint*)second.PixelDataPointer + (boundingRectangle.Top * secondUintStride) + uintIndex; 160 | var firstPointerExclusiveEnd = firstPointer + (boundingRectangle.Height * firstUintStride); 161 | 162 | while (firstPointer < firstPointerExclusiveEnd) 163 | { 164 | if (*firstPointer != *secondPointer) 165 | { 166 | // We don't know which changed of the two columns of pixels that this uint column is 167 | // overlapping, but it doesn't seem important enough to do a slower scan to find out. 168 | // Round to the right to be on the safe side. 169 | var x = (((uintIndex + 1) * sizeof(uint)) - 1) / first.BytesPerPixel; 170 | 171 | boundingRectangle.Width = Math.Min(boundingRectangle.Width, (ushort)(x - boundingRectangle.Left + 1)); 172 | return; 173 | } 174 | 175 | firstPointer += firstUintStride; 176 | secondPointer += secondUintStride; 177 | } 178 | } 179 | } 180 | 181 | boundingRectangle.Width = 1; 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/Extensions.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Runtime.InteropServices; 3 | 4 | namespace Techsola.InstantReplay 5 | { 6 | internal static class Extensions 7 | { 8 | public static T ThrowWithoutLastErrorAvailableIfInvalid(this T safeHandle, string apiName) 9 | where T : SafeHandle 10 | { 11 | if (safeHandle.IsInvalid) throw new Win32Exception(apiName + " failed."); 12 | return safeHandle; 13 | } 14 | 15 | public static T ThrowLastErrorIfInvalid(this T safeHandle) 16 | where T : SafeHandle 17 | { 18 | if (safeHandle.IsInvalid) throw new Win32Exception(); 19 | return safeHandle; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/Frame.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | using System.Runtime.InteropServices; 4 | using Techsola.InstantReplay.Native; 5 | using Windows.Win32; 6 | using Windows.Win32.Graphics.Gdi; 7 | 8 | namespace Techsola.InstantReplay 9 | { 10 | public static partial class InstantReplayCamera 11 | { 12 | private sealed class Frame : IDisposable 13 | { 14 | public const int BitsPerPixel = 24; 15 | 16 | private DeleteObjectSafeHandle? bitmap; 17 | private int bitmapWidth; 18 | private int bitmapHeight; 19 | 20 | public WindowMetrics WindowMetrics { get; private set; } 21 | public uint ZOrder { get; private set; } 22 | 23 | public void Dispose() 24 | { 25 | bitmap?.Dispose(); 26 | } 27 | 28 | public void Overwrite( 29 | DeleteDCSafeHandle bitmapDC, 30 | ref WindowDeviceContextSafeHandle windowDC, 31 | WindowMetrics windowMetrics, 32 | uint zOrder, 33 | ref bool needsGdiFlush) 34 | { 35 | if (windowMetrics.ClientWidth > 0 && windowMetrics.ClientHeight > 0) 36 | { 37 | if (bitmap is null || bitmapWidth < windowMetrics.ClientWidth || bitmapHeight < windowMetrics.ClientHeight) 38 | { 39 | if (bitmap is null) 40 | { 41 | // Most of the time, windows don't resize, so save some space by not rounding up. 42 | bitmapWidth = windowMetrics.ClientWidth; 43 | bitmapHeight = windowMetrics.ClientHeight; 44 | } 45 | else 46 | { 47 | // Round up to the nearest 256 pixels to minimize the number of times that bitmaps are 48 | // reallocated. 49 | bitmapWidth = ((Math.Max(bitmapWidth, windowMetrics.ClientWidth) + 255) / 256) * 256; 50 | bitmapHeight = ((Math.Max(bitmapHeight, windowMetrics.ClientHeight) + 255) / 256) * 256; 51 | 52 | bitmap.Dispose(); 53 | } 54 | 55 | var bitmapDCNeedsRelease = false; 56 | bitmapDC.DangerousAddRef(ref bitmapDCNeedsRelease); 57 | try 58 | { 59 | unsafe 60 | { 61 | var bitmapInfo = new BITMAPINFO 62 | { 63 | bmiHeader = 64 | { 65 | biSize = (uint)Marshal.SizeOf(typeof(BITMAPINFOHEADER)), 66 | biWidth = bitmapWidth, 67 | biHeight = -bitmapHeight, 68 | biPlanes = 1, 69 | biBitCount = BitsPerPixel, 70 | }, 71 | }; 72 | 73 | bitmap = PInvoke.CreateDIBSection((HDC)bitmapDC.DangerousGetHandle(), &bitmapInfo, DIB_USAGE.DIB_RGB_COLORS, ppvBits: out _, hSection: null, offset: 0).ThrowLastErrorIfInvalid(); 74 | } 75 | } 76 | finally 77 | { 78 | if (bitmapDCNeedsRelease) bitmapDC.DangerousRelease(); 79 | } 80 | } 81 | 82 | // Workaround for https://github.com/microsoft/CsWin32/issues/199 83 | if (PInvoke.SelectObject((HDC)bitmapDC.DangerousGetHandle(), (HGDIOBJ)bitmap.DangerousGetHandle()).IsNull) 84 | throw new Win32Exception("SelectObject failed."); 85 | 86 | retryBitBlt: 87 | PInvoke.SetLastError(0); // BitBlt doesn't set the last error if it returns false to indicate that the operation has been batched 88 | if (!PInvoke.BitBlt((HDC)bitmapDC.DangerousGetHandle(), 0, 0, windowMetrics.ClientWidth, windowMetrics.ClientHeight, (HDC)windowDC.DangerousGetHandle(), 0, 0, ROP_CODE.SRCCOPY)) 89 | { 90 | var lastError = Marshal.GetLastWin32Error(); 91 | if ((ERROR)lastError is ERROR.INVALID_WINDOW_HANDLE or ERROR.DC_NOT_FOUND) 92 | { 93 | windowDC.Dispose(); 94 | windowDC = new(windowDC.HWnd, PInvoke.GetDC(windowDC.HWnd)); 95 | if (windowDC.IsInvalid) 96 | { 97 | // This happens when the window goes away. Let this be detected on the next cycle, if it 98 | // was actually due to the window closing and not some other failure. Just make sure a 99 | // stale frame isn't drawn during this cycle. 100 | SetInvisible(); 101 | return; 102 | } 103 | 104 | goto retryBitBlt; 105 | } 106 | 107 | if (lastError != 0) throw new Win32Exception(lastError); 108 | needsGdiFlush = true; 109 | } 110 | else 111 | { 112 | needsGdiFlush = false; 113 | } 114 | } 115 | 116 | WindowMetrics = windowMetrics; 117 | ZOrder = zOrder; 118 | } 119 | 120 | public void SetInvisible() 121 | { 122 | WindowMetrics = default; 123 | } 124 | 125 | public void Compose( 126 | DeleteDCSafeHandle bitmapDC, 127 | DeleteDCSafeHandle compositionDC, 128 | (int X, int Y) compositionOffset, 129 | ref bool needsGdiFlush, 130 | out UInt16Rectangle changedArea) 131 | { 132 | if (bitmap is null || WindowMetrics.ClientWidth == 0 || WindowMetrics.ClientHeight == 0) 133 | { 134 | changedArea = default; 135 | return; 136 | } 137 | 138 | // Workaround for https://github.com/microsoft/CsWin32/issues/199 139 | if (PInvoke.SelectObject((HDC)bitmapDC.DangerousGetHandle(), (HGDIOBJ)bitmap.DangerousGetHandle()).IsNull) 140 | throw new Win32Exception("SelectObject failed."); 141 | 142 | changedArea = new( 143 | (ushort)(WindowMetrics.ClientLeft + compositionOffset.X), 144 | (ushort)(WindowMetrics.ClientTop + compositionOffset.Y), 145 | (ushort)WindowMetrics.ClientWidth, 146 | (ushort)WindowMetrics.ClientHeight); 147 | 148 | PInvoke.SetLastError(0); // BitBlt doesn't set the last error if it returns false to indicate that the operation has been batched 149 | if (!PInvoke.BitBlt( 150 | (HDC)compositionDC.DangerousGetHandle(), 151 | changedArea.Left, 152 | changedArea.Top, 153 | changedArea.Width, 154 | changedArea.Height, 155 | (HDC)bitmapDC.DangerousGetHandle(), 156 | 0, 157 | 0, 158 | ROP_CODE.SRCCOPY)) 159 | { 160 | var lastError = Marshal.GetLastWin32Error(); 161 | if (lastError != 0) throw new Win32Exception(lastError); 162 | needsGdiFlush = true; 163 | } 164 | else 165 | { 166 | needsGdiFlush = false; 167 | } 168 | } 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/FrequencyLimit.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Techsola.InstantReplay 4 | { 5 | internal readonly struct FrequencyLimit 6 | { 7 | public FrequencyLimit(uint maximumCount, TimeSpan withinDuration) 8 | { 9 | MaximumCount = maximumCount; 10 | WithinDuration = withinDuration; 11 | } 12 | 13 | public uint MaximumCount { get; } 14 | public TimeSpan WithinDuration { get; } 15 | 16 | public override string ToString() => $"Up to {MaximumCount} times within {WithinDuration}"; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/FrequencyLimiter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | 4 | namespace Techsola.InstantReplay 5 | { 6 | internal sealed class FrequencyLimiter 7 | { 8 | private readonly long stopwatchTimestampDuration; 9 | private readonly long?[] occurrences; 10 | private int nextIndex; 11 | 12 | public FrequencyLimiter(FrequencyLimit limit) 13 | { 14 | stopwatchTimestampDuration = (limit.WithinDuration.Ticks / TimeSpan.TicksPerSecond) * Stopwatch.Frequency; 15 | occurrences = new long?[limit.MaximumCount]; 16 | } 17 | 18 | public bool TryAddOccurrence(long stopwatchTimestamp) 19 | { 20 | if (stopwatchTimestampDuration == 0 || occurrences.Length == 0) return false; 21 | 22 | if (occurrences[(nextIndex == 0 ? occurrences.Length : nextIndex) - 1] is { } previousTimestamp 23 | && stopwatchTimestamp < previousTimestamp) 24 | { 25 | throw new ArgumentOutOfRangeException( 26 | nameof(stopwatchTimestamp), 27 | stopwatchTimestamp, 28 | "The stopwatch timestamp must not be earlier than the last reported timestamp."); 29 | } 30 | 31 | if (occurrences[nextIndex] is { } oldestTimestamp 32 | && (stopwatchTimestamp - oldestTimestamp) < stopwatchTimestampDuration) 33 | { 34 | return false; 35 | } 36 | 37 | occurrences[nextIndex] = stopwatchTimestamp; 38 | nextIndex++; 39 | if (nextIndex == occurrences.Length) nextIndex = 0; 40 | return true; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/GifWriter.GifImageDataChunker.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | 3 | namespace Techsola.InstantReplay 4 | { 5 | partial class GifWriter 6 | { 7 | private struct GifImageDataChunker 8 | { 9 | const byte MaxChunkLength = 255; 10 | 11 | private readonly BinaryWriter writer; 12 | private readonly byte[] buffer; 13 | private byte nextIndex; 14 | 15 | public GifImageDataChunker(BinaryWriter writer) 16 | { 17 | this.writer = writer; 18 | buffer = new byte[MaxChunkLength]; 19 | nextIndex = 0; 20 | } 21 | 22 | public void AddByte(byte data) 23 | { 24 | buffer[nextIndex] = data; 25 | nextIndex++; 26 | 27 | if (nextIndex == MaxChunkLength) FlushCore(); 28 | } 29 | 30 | public void Flush() 31 | { 32 | if (nextIndex > 0) FlushCore(); 33 | } 34 | 35 | private void FlushCore() 36 | { 37 | writer.Write(nextIndex); 38 | writer.Write(buffer, 0, nextIndex); 39 | nextIndex = 0; 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/GifWriter.GifLzwBitPacker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | 4 | namespace Techsola.InstantReplay 5 | { 6 | partial class GifWriter 7 | { 8 | private struct GifLzwBitPacker 9 | { 10 | private GifImageDataChunker chunker; 11 | private byte buffer; 12 | 13 | /// 14 | /// Always between 0 and 7. 15 | /// 16 | private byte nextBit; 17 | 18 | public GifLzwBitPacker(BinaryWriter writer) 19 | { 20 | chunker = new(writer); 21 | buffer = 0; 22 | nextBit = 0; 23 | } 24 | 25 | public void WriteCode(ushort code, byte codeLength) 26 | { 27 | if (codeLength > 16) 28 | throw new ArgumentOutOfRangeException(nameof(codeLength), codeLength, "Code length must not be greater than the number of bits in the code parameter type."); 29 | 30 | if (code >= (1u << codeLength)) 31 | throw new ArgumentException("The specified code has bits set outside the range allowed by the specified code length."); 32 | 33 | while (codeLength > 0) 34 | { 35 | var bufferBitsRemaining = (byte)(8 - nextBit); 36 | 37 | buffer |= unchecked((byte)(code << nextBit)); 38 | 39 | if (codeLength < bufferBitsRemaining) 40 | { 41 | nextBit += codeLength; 42 | break; 43 | } 44 | 45 | FlushCore(); 46 | 47 | codeLength -= bufferBitsRemaining; 48 | code >>= bufferBitsRemaining; 49 | } 50 | } 51 | 52 | public void Flush() 53 | { 54 | if (nextBit > 0) FlushCore(); 55 | 56 | chunker.Flush(); 57 | } 58 | 59 | private void FlushCore() 60 | { 61 | chunker.AddByte(buffer); 62 | buffer = 0; 63 | nextBit = 0; 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/GifWriter.GraphNode.cs: -------------------------------------------------------------------------------- 1 | namespace Techsola.InstantReplay 2 | { 3 | partial class GifWriter 4 | { 5 | private sealed class GraphNode 6 | { 7 | // Inlined fields for sequential scan 8 | private GraphNode? childNode1, childNode2, childNode3, childNode4; 9 | private byte childKey1, childKey2, childKey3, childKey4; 10 | 11 | private GraphNode?[]? randomAccess; 12 | 13 | public GraphNode(ushort code) 14 | { 15 | Code = code; 16 | } 17 | 18 | public ushort Code { get; } 19 | 20 | public GraphNode GetOrAddChildNode(byte childKey, ushort nextCode, out bool didAdd) 21 | { 22 | GraphNode? childNode; 23 | 24 | if (randomAccess is not null) 25 | { 26 | childNode = randomAccess[childKey]; 27 | if (childNode is null) 28 | { 29 | childNode = new(nextCode); 30 | randomAccess[childKey] = childNode; 31 | didAdd = true; 32 | } 33 | else 34 | { 35 | didAdd = false; 36 | } 37 | 38 | return childNode; 39 | } 40 | 41 | childNode = 42 | childKey == childKey1 ? childNode1 : 43 | childKey == childKey2 ? childNode2 : 44 | childKey == childKey3 ? childNode3 : 45 | childKey == childKey4 ? childNode4 : 46 | null; 47 | 48 | if (childNode is not null) 49 | { 50 | didAdd = false; 51 | return childNode; 52 | } 53 | 54 | childNode = new(nextCode); 55 | 56 | if (childNode1 is null) { childNode1 = childNode; childKey1 = childKey; } 57 | else if (childNode2 is null) { childNode2 = childNode; childKey2 = childKey; } 58 | else if (childNode3 is null) { childNode3 = childNode; childKey3 = childKey; } 59 | else if (childNode4 is null) { childNode4 = childNode; childKey4 = childKey; } 60 | else 61 | { 62 | randomAccess = new GraphNode[256]; 63 | randomAccess[childKey] = childNode; 64 | } 65 | 66 | didAdd = true; 67 | return childNode; 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/GifWriter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Text; 4 | 5 | namespace Techsola.InstantReplay 6 | { 7 | /// 8 | /// Implements . 9 | /// 10 | internal sealed partial class GifWriter 11 | { 12 | private readonly BinaryWriter writer; 13 | 14 | public GifWriter(Stream stream) 15 | { 16 | // BinaryWriter does not have its own buffer to flush and does not do anything when disposed other than 17 | // dispose the stream if leaveOpen is false and flush the stream if leaveOpen is true. 18 | #if !NET35 19 | writer = new(stream, Encoding.ASCII, leaveOpen: true); 20 | #else 21 | writer = new(stream, Encoding.ASCII); 22 | #endif 23 | } 24 | 25 | private static readonly byte[] HeaderSignatureAndVersion = 26 | { 27 | (byte)'G', 28 | (byte)'I', 29 | (byte)'F', 30 | (byte)'8', 31 | (byte)'9', 32 | (byte)'a', 33 | }; 34 | 35 | /// 36 | /// Writes the required header block and the required logical screen descriptor block which must immediately 37 | /// follow it. 38 | /// 39 | public void BeginStream( 40 | ushort width, 41 | ushort height, 42 | bool globalColorTable, 43 | byte sourceImageBitsPerPrimaryColor, 44 | bool globalColorTableIsSorted, 45 | byte globalColorTableSize, 46 | byte globalColorTableBackgroundColorIndex, 47 | byte biasedPixelAspectRatioIn64ths = 0) 48 | { 49 | if (sourceImageBitsPerPrimaryColor < 1 || 8 < sourceImageBitsPerPrimaryColor) 50 | throw new ArgumentOutOfRangeException(nameof(sourceImageBitsPerPrimaryColor), sourceImageBitsPerPrimaryColor, "Source image bits per primary color must be between 1 and 8."); 51 | 52 | if (7 < globalColorTableSize) 53 | throw new ArgumentOutOfRangeException(nameof(globalColorTableSize), globalColorTableSize, "Global color table size must be between 0 and 7."); 54 | 55 | // Header block 56 | writer.Write(HeaderSignatureAndVersion); 57 | 58 | // Logical screen descriptor block 59 | writer.Write(width); 60 | writer.Write(height); 61 | writer.Write((byte)( 62 | (globalColorTable ? 0b1000_0000 : 0) 63 | | ((sourceImageBitsPerPrimaryColor - 1) << 4) 64 | | (globalColorTableIsSorted ? 0b1000 : 0) 65 | | globalColorTableSize)); 66 | writer.Write(globalColorTableBackgroundColorIndex); 67 | writer.Write(biasedPixelAspectRatioIn64ths); 68 | } 69 | 70 | /// 71 | /// If present, this block must appear immediately after the global color table of the logical screen 72 | /// descriptor. 73 | /// 74 | /// A loop count of 0 specifies infinite looping. 75 | public void WriteLoopingExtensionBlock(ushort loopCount = 0) 76 | { 77 | BeginApplicationExtension(NetscapeApplicationIdentifierAndCode); 78 | 79 | BeginDataSubBlock(dataSize: 3); 80 | const byte loopCountSubBlockId = 0x01; 81 | writer.Write(loopCountSubBlockId); 82 | writer.Write(loopCount); 83 | 84 | WriteBlockTerminator(); 85 | } 86 | 87 | /// 88 | /// This block may appear at most once before each image descriptor. 89 | /// 90 | public void WriteGraphicControlExtensionBlock( 91 | ushort delayInHundredthsOfASecond = 0, 92 | byte? transparentColorIndex = null) 93 | { 94 | BeginExtension(label: 0xF9, blockSize: 4); 95 | 96 | const byte disposalMethod = 0; // 0 = Not specified 97 | const bool waitForUserInput = false; 98 | 99 | writer.Write((byte)( 100 | (disposalMethod << 2) 101 | | (waitForUserInput ? 0b10 : 0) 102 | | (transparentColorIndex is not null ? 1 : 0))); 103 | writer.Write(delayInHundredthsOfASecond); 104 | writer.Write(transparentColorIndex ?? 0); 105 | 106 | WriteBlockTerminator(); 107 | } 108 | 109 | public void WriteImageDescriptor( 110 | ushort left, 111 | ushort top, 112 | ushort width, 113 | ushort height, 114 | bool localColorTable, 115 | bool isInterlaced, 116 | bool localColorTableIsSorted, 117 | byte localColorTableSize) 118 | { 119 | if (7 < localColorTableSize) 120 | throw new ArgumentOutOfRangeException(nameof(localColorTableSize), localColorTableSize, "Local color table size must be between 0 and 7."); 121 | 122 | writer.Write((byte)0x2C); 123 | writer.Write(left); 124 | writer.Write(top); 125 | writer.Write(width); 126 | writer.Write(height); 127 | writer.Write((byte)( 128 | (localColorTable ? 0b1000_0000 : 0) 129 | | (isInterlaced ? 0b0100_0000 : 0) 130 | | (localColorTableIsSorted ? 0b0010_0000 : 0) 131 | | localColorTableSize)); 132 | } 133 | 134 | /// 135 | /// Immediately follows the logical screen descriptor if it has global color table and each image descriptor 136 | /// that has a local color table. 137 | /// 138 | public void WriteColorTable((byte R, byte G, byte B)[] paletteBuffer, int paletteLength) 139 | { 140 | if (paletteLength is not (2 or 4 or 8 or 16 or 32 or 64 or 128 or 256)) 141 | throw new ArgumentOutOfRangeException(nameof(paletteLength), paletteLength, "The palette length must be a power of 2 between 2 and 256."); 142 | 143 | if (paletteBuffer.Length < paletteLength) 144 | throw new ArgumentException("The palette length must be less than or equal to the length of the buffer."); 145 | 146 | for (var i = 0; i < paletteLength; i++) 147 | { 148 | var (r, g, b) = paletteBuffer[i]; 149 | writer.Write(r); 150 | writer.Write(g); 151 | writer.Write(b); 152 | } 153 | } 154 | 155 | /// 156 | /// Immediately follows each local color table and each image descriptor that has no local color table. 157 | /// 158 | public void WriteImageData(byte[] indexedImagePixels, uint indexedImageLength, byte bitsPerIndexedPixel) 159 | { 160 | // https://www.w3.org/Graphics/GIF/spec-gif89a.txt, page 31, "ESTABLISH CODE SIZE" 161 | if (bitsPerIndexedPixel < 2) bitsPerIndexedPixel = 2; 162 | 163 | writer.Write(bitsPerIndexedPixel); 164 | 165 | var currentCodeSize = (byte)(bitsPerIndexedPixel + 1); 166 | var clearCode = (ushort)(1u << bitsPerIndexedPixel); 167 | var endOfInformationCode = (ushort)(clearCode + 1); 168 | 169 | var bitPacker = new GifLzwBitPacker(writer); 170 | 171 | // Spec requires this to be the first code 172 | bitPacker.WriteCode(clearCode, currentCodeSize); 173 | 174 | if (indexedImageLength > 0) 175 | { 176 | var nextCode = (ushort)(endOfInformationCode + 1); 177 | 178 | var multibyteCodeRoots = new GraphNode?[256]; 179 | 180 | var currentIndex = 0; 181 | while (true) 182 | { 183 | var currentLength = 1; 184 | var didAddChildNode = false; 185 | 186 | var rootCode = indexedImagePixels[currentIndex]; 187 | var currentNode = multibyteCodeRoots[rootCode] ??= new(rootCode); 188 | 189 | while (currentIndex + currentLength < indexedImageLength) 190 | { 191 | currentLength++; 192 | 193 | var childNode = currentNode.GetOrAddChildNode(indexedImagePixels[currentIndex + currentLength - 1], nextCode, out didAddChildNode); 194 | if (didAddChildNode) 195 | { 196 | nextCode++; 197 | break; 198 | } 199 | 200 | currentNode = childNode; 201 | } 202 | 203 | bitPacker.WriteCode(currentNode.Code, currentCodeSize); 204 | 205 | if (!didAddChildNode) 206 | { 207 | // Being here means that currentLength was equal to remainingBytes.Length. 208 | break; 209 | } 210 | 211 | const ushort maxAllowedCodeValue = 4095; 212 | if (nextCode > maxAllowedCodeValue) 213 | { 214 | bitPacker.WriteCode(clearCode, currentCodeSize); 215 | 216 | currentCodeSize = (byte)(bitsPerIndexedPixel + 1); 217 | nextCode = (ushort)(endOfInformationCode + 1); 218 | Array.Clear(multibyteCodeRoots, 0, multibyteCodeRoots.Length); 219 | } 220 | else if (nextCode > 1u << currentCodeSize) 221 | { 222 | currentCodeSize++; 223 | } 224 | 225 | currentIndex += currentLength - 1; 226 | } 227 | } 228 | 229 | // Spec requires this to be the last code 230 | bitPacker.WriteCode(endOfInformationCode, currentCodeSize); 231 | bitPacker.Flush(); 232 | 233 | WriteBlockTerminator(); 234 | } 235 | 236 | public void EndStream() 237 | { 238 | writer.Write((byte)0x3B); 239 | } 240 | 241 | private static readonly byte[] NetscapeApplicationIdentifierAndCode = 242 | { 243 | (byte)'N', 244 | (byte)'E', 245 | (byte)'T', 246 | (byte)'S', 247 | (byte)'C', 248 | (byte)'A', 249 | (byte)'P', 250 | (byte)'E', 251 | (byte)'2', 252 | (byte)'.', 253 | (byte)'0', 254 | }; 255 | 256 | private void BeginApplicationExtension(byte[] applicationIdentifierAndCode) 257 | { 258 | if (applicationIdentifierAndCode.Length != 11) 259 | throw new ArgumentException("The application identifier must be 8 bytes and the application authentication code must be 3 bytes.", nameof(applicationIdentifierAndCode)); 260 | 261 | BeginExtension(label: 0xFF, blockSize: 11); 262 | writer.Write(applicationIdentifierAndCode); 263 | } 264 | 265 | private void BeginExtension(byte label, byte blockSize) 266 | { 267 | writer.Write((byte)0x21); 268 | writer.Write(label); 269 | writer.Write(blockSize); 270 | } 271 | 272 | private void BeginDataSubBlock(byte dataSize) 273 | { 274 | writer.Write(dataSize); 275 | } 276 | 277 | private void WriteBlockTerminator() 278 | { 279 | BeginDataSubBlock(dataSize: 0); 280 | } 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/InstantReplayCamera.CompositionRenderer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Techsola.InstantReplay.Native; 3 | using Windows.Win32.UI.WindowsAndMessaging; 4 | 5 | namespace Techsola.InstantReplay 6 | { 7 | partial class InstantReplayCamera 8 | { 9 | private readonly struct CompositionRenderer 10 | { 11 | private readonly (long Timestamp, (int X, int Y, HCURSOR Handle)? Cursor)[] frames; 12 | private readonly List framesByWindow; 13 | private readonly (int X, int Y) compositionOffset; 14 | private readonly AnimatedCursorRenderer cursorRenderer; 15 | 16 | public uint FrameCount { get; } 17 | public ushort CompositionWidth { get; } 18 | public ushort CompositionHeight { get; } 19 | 20 | public CompositionRenderer((long Timestamp, (int X, int Y, HCURSOR Handle)? Cursor)[] frames, List framesByWindow) 21 | { 22 | this.frames = frames; 23 | this.framesByWindow = framesByWindow; 24 | 25 | var minLeft = int.MaxValue; 26 | var maxRight = int.MinValue; 27 | var minTop = int.MaxValue; 28 | var maxBottom = int.MinValue; 29 | var maxFrameCount = 0u; 30 | 31 | foreach (var frameList in framesByWindow) 32 | { 33 | for (var i = 0u; i < frameList.Length; i++) 34 | { 35 | if (frameList[i]?.WindowMetrics is not { ClientWidth: > 0, ClientHeight: > 0 } metrics) continue; 36 | 37 | var frameCount = (uint)frameList.Length - i; 38 | if (maxFrameCount < frameCount) maxFrameCount = frameCount; 39 | 40 | if (minLeft > metrics.ClientLeft) minLeft = metrics.ClientLeft; 41 | if (minTop > metrics.ClientTop) minTop = metrics.ClientTop; 42 | if (maxRight < metrics.ClientLeft + metrics.ClientWidth) maxRight = metrics.ClientLeft + metrics.ClientWidth; 43 | if (maxBottom < metrics.ClientTop + metrics.ClientHeight) maxBottom = metrics.ClientTop + metrics.ClientHeight; 44 | } 45 | } 46 | 47 | FrameCount = maxFrameCount; 48 | 49 | if (maxFrameCount == 0) 50 | { 51 | compositionOffset = default; 52 | CompositionWidth = default; 53 | CompositionHeight = default; 54 | } 55 | else 56 | { 57 | compositionOffset = (X: -minLeft, Y: -minTop); 58 | CompositionWidth = checked((ushort)(maxRight - minLeft)); 59 | CompositionHeight = checked((ushort)(maxBottom - minTop)); 60 | } 61 | 62 | cursorRenderer = new(); 63 | } 64 | 65 | public void Compose( 66 | int frameIndex, 67 | Composition buffer, 68 | DeleteDCSafeHandle bitmapDC, 69 | ref bool needsGdiFlush, 70 | out UInt16Rectangle changedArea) 71 | { 72 | var windowFramesToDraw = new List(); 73 | 74 | foreach (var frameList in framesByWindow) 75 | { 76 | var index = frameIndex - FrameCount + frameList.Length; 77 | if (index >= 0 && frameList[index] is { WindowMetrics: { ClientWidth: > 0, ClientHeight: > 0 } } windowFrame) 78 | windowFramesToDraw.Add(windowFrame); 79 | } 80 | 81 | windowFramesToDraw.Sort((a, b) => b.ZOrder.CompareTo(a.ZOrder)); 82 | 83 | changedArea = default; 84 | 85 | foreach (var windowFrame in windowFramesToDraw) 86 | { 87 | windowFrame.Compose(bitmapDC, buffer.DeviceContext, compositionOffset, ref needsGdiFlush, out var additionalChangedArea); 88 | changedArea = changedArea.Union(additionalChangedArea); 89 | } 90 | 91 | var frame = frames[frameIndex - FrameCount + frames.Length]; 92 | 93 | if (frame.Cursor is { } cursor) 94 | { 95 | cursorRenderer.Render(buffer.DeviceContext, cursor.Handle, cursor.X + compositionOffset.X, cursor.Y + compositionOffset.Y, out var additionalChangedArea); 96 | changedArea = changedArea.Union(additionalChangedArea); 97 | } 98 | 99 | changedArea = changedArea.Intersect(new(0, 0, CompositionWidth, CompositionHeight)); 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/InstantReplayCamera.FrameSink.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | 3 | namespace Techsola.InstantReplay 4 | { 5 | partial class InstantReplayCamera 6 | { 7 | private struct FrameSink 8 | { 9 | private readonly MemoryStream stream; 10 | private readonly GifWriter writer; 11 | private readonly WuQuantizer quantizer; 12 | private readonly (byte R, byte G, byte B)[] paletteBuffer; 13 | private readonly byte[] indexedImageBuffer; 14 | 15 | public FrameSink(ushort compositionWidth, ushort compositionHeight) 16 | { 17 | stream = new MemoryStream(); 18 | writer = new GifWriter(stream); 19 | 20 | writer.BeginStream( 21 | compositionWidth, 22 | compositionHeight, 23 | globalColorTable: false, // TODO: optimize to use the global color table for the majority palette if more than one frame can use the same palette 24 | sourceImageBitsPerPrimaryColor: 8, // Actually 24, but this is the maximum value. Not used anyway. 25 | globalColorTableIsSorted: false, 26 | globalColorTableSize: 0, 27 | globalColorTableBackgroundColorIndex: 0); 28 | 29 | writer.WriteLoopingExtensionBlock(); 30 | 31 | quantizer = new(); 32 | 33 | paletteBuffer = new (byte R, byte G, byte B)[256]; 34 | indexedImageBuffer = new byte[compositionWidth * compositionHeight]; 35 | } 36 | 37 | public void EmitFrame(Composition source, UInt16Rectangle boundingRectangle, ushort delayInHundredthsOfASecond) 38 | { 39 | quantizer.Quantize( 40 | source.EnumerateRange(boundingRectangle), 41 | paletteBuffer, 42 | out var paletteLength, 43 | indexedImageBuffer, 44 | out var indexedImageLength); 45 | 46 | var bitsPerIndexedPixel = GetBitsPerPixel(paletteLength); 47 | 48 | writer.WriteGraphicControlExtensionBlock(delayInHundredthsOfASecond, transparentColorIndex: null); 49 | 50 | writer.WriteImageDescriptor( 51 | left: boundingRectangle.Left, 52 | top: boundingRectangle.Top, 53 | width: boundingRectangle.Width, 54 | height: boundingRectangle.Height, 55 | localColorTable: true, 56 | isInterlaced: false, 57 | localColorTableIsSorted: false, 58 | localColorTableSize: (byte)(bitsPerIndexedPixel - 1)); // Means 2^(localColorTableSize+1) entries 59 | 60 | writer.WriteColorTable(paletteBuffer, paletteLength: 1 << bitsPerIndexedPixel); 61 | 62 | writer.WriteImageData(indexedImageBuffer, indexedImageLength, bitsPerIndexedPixel); 63 | } 64 | 65 | public byte[] End() 66 | { 67 | writer.EndStream(); 68 | return stream.ToArray(); 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/InstantReplayCamera.WindowInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Techsola.InstantReplay.Native; 3 | 4 | namespace Techsola.InstantReplay 5 | { 6 | partial class InstantReplayCamera 7 | { 8 | private sealed class WindowState : IDisposable 9 | { 10 | private WindowDeviceContextSafeHandle? windowDC; 11 | private readonly CircularBuffer frames; 12 | private int disposedFrameCount; 13 | 14 | public long FirstSeen { get; } 15 | public long LastSeen { get; set; } 16 | 17 | public WindowState(WindowDeviceContextSafeHandle windowDC, long firstSeen, int bufferSize) 18 | { 19 | this.windowDC = windowDC; 20 | FirstSeen = firstSeen; 21 | LastSeen = firstSeen; 22 | frames = new(bufferSize); 23 | } 24 | 25 | public void Dispose() 26 | { 27 | foreach (var frame in frames.GetRawBuffer()) 28 | frame?.Dispose(); 29 | 30 | windowDC?.Dispose(); 31 | } 32 | 33 | public void AddFrame( 34 | DeleteDCSafeHandle bitmapDC, 35 | WindowMetrics windowMetrics, 36 | uint zOrder, 37 | ref bool needsGdiFlush) 38 | { 39 | if (windowDC is null) throw new InvalidOperationException("The window is closed."); 40 | 41 | var frame = frames.GetNextRef() ??= new(); 42 | frame.Overwrite(bitmapDC, ref windowDC, windowMetrics, zOrder, ref needsGdiFlush); 43 | } 44 | 45 | public void AddInvisibleFrame() 46 | { 47 | frames.GetNextRef()?.SetInvisible(); 48 | } 49 | 50 | public void MarkClosed() 51 | { 52 | if (windowDC is null) return; 53 | windowDC.Dispose(); 54 | windowDC = null; 55 | } 56 | 57 | public void DisposeNextFrame(out bool allFramesDisposed) 58 | { 59 | if (frames.Count == 0) 60 | { 61 | allFramesDisposed = true; 62 | return; 63 | } 64 | 65 | ref var frameRef = ref frames.GetNextRef(); 66 | frameRef?.Dispose(); 67 | frameRef = null; 68 | 69 | disposedFrameCount++; 70 | allFramesDisposed = disposedFrameCount >= frames.Capacity; 71 | } 72 | 73 | public Frame?[] GetFramesSnapshot() => frames.ToArray(); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/InstantReplayCamera.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Diagnostics; 5 | using System.Drawing; 6 | using System.Linq; 7 | using System.Runtime.InteropServices; 8 | using System.Threading; 9 | using Techsola.InstantReplay.Native; 10 | using Windows.Win32; 11 | using Windows.Win32.Foundation; 12 | using Windows.Win32.UI.WindowsAndMessaging; 13 | 14 | namespace Techsola.InstantReplay 15 | { 16 | /// 17 | /// 18 | /// Buffers timed screenshots for all windows in the current process and tracks the mouse cursor so that an animated 19 | /// GIF can be created on demand, for inclusion in a crash report for example. 20 | /// 21 | /// 22 | /// Call once when the application starts, and then call to obtain a GIF 23 | /// of the last ten seconds up to that point in time. 24 | /// 25 | /// 26 | public static partial class InstantReplayCamera 27 | { 28 | private const int MillisecondsBeforeBitBltingNewWindow = 300; 29 | private const int FramesPerSecond = 10; 30 | private const int DurationInSeconds = 10; 31 | private const int BufferSize = DurationInSeconds * FramesPerSecond; 32 | 33 | private static readonly FrequencyLimiter BackgroundExceptionReportLimiter = new( 34 | new(maximumCount: 3, withinDuration: TimeSpan.FromHours(1))); 35 | 36 | private static Timer? timer; 37 | private static Action? reportBackgroundException; 38 | private static WindowEnumerator? windowEnumerator; 39 | private static DeleteDCSafeHandle? bitmapDC; 40 | private static readonly object FrameLock = new(); 41 | private static readonly Dictionary InfoByWindowHandle = new(); 42 | private static readonly CircularBuffer<(long Timestamp, (int X, int Y, HCURSOR Handle)? Cursor)> Frames = new(BufferSize); 43 | private static bool isDisabled; 44 | 45 | private static readonly SharedResultMutex SaveGifSharedResultMutex = new(SaveGifCore); 46 | 47 | /// 48 | /// 49 | /// Begins buffering up to ten seconds of screenshots for all windows in the current process, including windows 50 | /// that have not been created yet, as well as the mouse cursor. 51 | /// 52 | /// 53 | /// Call this during the start of your application. will only have access to frames that 54 | /// occurred after this call. Subsequent calls to this method have no effect. 55 | /// 56 | /// 57 | /// This method is thread-safe and does not behave differently when called from the UI thread or any other 58 | /// thread. 59 | /// 60 | /// 61 | /// 62 | /// 63 | /// Please report exceptions just as you would for and other 64 | /// top-level exception events such as TaskScheduler.UnobservedTaskException and 65 | /// Application.ThreadException. When you come across an exception that appears to be a flaw in 66 | /// Techsola.InstantReplay, please report it at . 67 | /// 68 | /// 69 | /// Ideally there will be no unhandled exceptions, but they are a normal part of the development cycle. This 70 | /// parameter is provided so that the runtime does not forcibly terminate your app due to an exception in the 71 | /// timer callback in Techsola.InstantReplay. 72 | /// 73 | /// 74 | public static void Start(Action reportBackgroundException) 75 | { 76 | if (reportBackgroundException is null) throw new ArgumentNullException(nameof(reportBackgroundException)); 77 | 78 | if (Interlocked.CompareExchange(ref InstantReplayCamera.reportBackgroundException, reportBackgroundException, null) is not null) 79 | { 80 | // This method has been called before. Ignore. 81 | return; 82 | } 83 | 84 | // Consider varying timer frequency when there are no visible windows to e.g. 1 second 85 | timer = new Timer(AddFrames, state: null, dueTime: TimeSpan.Zero, period: TimeSpan.FromSeconds(1.0 / FramesPerSecond)); 86 | } 87 | 88 | private static void AddFrames(object? state) 89 | { 90 | if (isDisabled) return; 91 | 92 | var now = Stopwatch.GetTimestamp(); 93 | 94 | try 95 | { 96 | var lockTaken = false; 97 | try 98 | { 99 | #if NET35 100 | lockTaken = Monitor.TryEnter(FrameLock); 101 | #else 102 | Monitor.TryEnter(FrameLock, ref lockTaken); 103 | #endif 104 | if (!lockTaken) return; 105 | 106 | if (isDisabled) return; 107 | 108 | var cursorInfo = new CURSORINFO { cbSize = (uint)Marshal.SizeOf(typeof(CURSORINFO)) }; 109 | if (!PInvoke.GetCursorInfo(ref cursorInfo)) 110 | { 111 | var lastError = Marshal.GetLastWin32Error(); 112 | // Access is denied while the workstation is locked. 113 | if ((ERROR)lastError != ERROR.ACCESS_DENIED) 114 | throw new Win32Exception(lastError); 115 | } 116 | 117 | var currentWindows = (windowEnumerator ??= new()).GetCurrentWindowHandlesInZOrder(); 118 | 119 | bitmapDC ??= new DeleteDCSafeHandle(PInvoke.CreateCompatibleDC(default)).ThrowWithoutLastErrorAvailableIfInvalid(nameof(PInvoke.CreateCompatibleDC)); 120 | 121 | lock (InfoByWindowHandle) 122 | { 123 | Frames.Add(( 124 | Timestamp: now, 125 | Cursor: (cursorInfo.flags & (CURSORINFO_FLAGS.CURSOR_SHOWING | CURSORINFO_FLAGS.CURSOR_SUPPRESSED)) == CURSORINFO_FLAGS.CURSOR_SHOWING 126 | ? (cursorInfo.ptScreenPos.X, cursorInfo.ptScreenPos.Y, cursorInfo.hCursor) 127 | : null)); 128 | 129 | var zOrder = 0u; 130 | var needsGdiFlush = false; 131 | 132 | foreach (var window in currentWindows) 133 | { 134 | if (!InfoByWindowHandle.TryGetValue(window, out var windowState)) 135 | { 136 | // The window hasn't been seen before 137 | if (PInvoke.IsWindowVisible(window) 138 | && new WindowDeviceContextSafeHandle(window, PInvoke.GetDC(window)) is { IsInvalid: false } windowDC) 139 | { 140 | windowState = new(windowDC, firstSeen: now, BufferSize); 141 | InfoByWindowHandle.Add(window, windowState); 142 | } 143 | } 144 | else 145 | { 146 | // The window has been seen before 147 | if ((now - windowState.FirstSeen) < Stopwatch.Frequency * MillisecondsBeforeBitBltingNewWindow / 1000) 148 | { 149 | // No frames have been added yet 150 | } 151 | else if (!PInvoke.IsWindowVisible(window)) 152 | { 153 | windowState.AddInvisibleFrame(); 154 | } 155 | else if (GetWindowMetricsIfExists(window) is { } metrics) 156 | { 157 | windowState.AddFrame(bitmapDC, metrics, zOrder, ref needsGdiFlush); 158 | zOrder++; 159 | } 160 | else 161 | { 162 | // The window will be detected as closed 163 | continue; 164 | } 165 | 166 | windowState.LastSeen = now; // Keeps the window from being detected as closed 167 | } 168 | } 169 | 170 | // Make sure to flush on the same thread that called the GDI function in case this thread goes away. 171 | // (https://docs.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-gdiflush#remarks) 172 | if (needsGdiFlush && !PInvoke.GdiFlush()) 173 | throw new Win32Exception("GdiFlush failed."); 174 | 175 | var closedWindowsWithNoFrames = new List(); 176 | 177 | foreach (var entry in InfoByWindowHandle) 178 | { 179 | if (entry.Value.LastSeen != now) 180 | { 181 | entry.Value.MarkClosed(); 182 | entry.Value.DisposeNextFrame(out var allFramesDisposed); 183 | 184 | if (allFramesDisposed) 185 | { 186 | entry.Value.Dispose(); 187 | closedWindowsWithNoFrames.Add(entry.Key); 188 | } 189 | } 190 | } 191 | 192 | foreach (var window in closedWindowsWithNoFrames) 193 | InfoByWindowHandle.Remove(window); 194 | } 195 | } 196 | finally 197 | { 198 | if (lockTaken) Monitor.Exit(FrameLock); 199 | } 200 | } 201 | #pragma warning disable CA1031 // If this is not caught, the runtime forcibly terminates the app. 202 | catch (Exception ex) 203 | #pragma warning restore CA1031 204 | { 205 | if (BackgroundExceptionReportLimiter.TryAddOccurrence(now)) 206 | { 207 | reportBackgroundException!.Invoke(ex); 208 | } 209 | else 210 | { 211 | isDisabled = true; 212 | timer!.Dispose(); 213 | } 214 | } 215 | } 216 | 217 | private static WindowMetrics? GetWindowMetricsIfExists(HWND window) 218 | { 219 | var clientTopLeft = default(Point); 220 | if (!PInvoke.ClientToScreen(window, ref clientTopLeft)) 221 | return null; // This is what happens when the window handle becomes invalid. 222 | 223 | if (!PInvoke.GetClientRect(window, out var clientRect)) 224 | { 225 | var lastError = Marshal.GetLastWin32Error(); 226 | if ((ERROR)lastError == ERROR.INVALID_WINDOW_HANDLE) return null; 227 | throw new Win32Exception(lastError); 228 | } 229 | 230 | return new(clientTopLeft.X, clientTopLeft.Y, clientRect.right, clientRect.bottom); 231 | } 232 | 233 | #if !NET35 234 | /// 235 | /// 236 | /// Blocks while synchronously compositing, quantizing, and encoding all buffered screenshots and cursor 237 | /// movements and writing them to the array that is returned. No frames are erased by this call, and no new 238 | /// frames are buffered while this method is executing. 239 | /// 240 | /// 241 | /// ⚠ Consider using to prevent the CPU-intensive 242 | /// quantizing and encoding from making the application unresponsive. 243 | /// 244 | /// 245 | /// This method is thread-safe and does not behave differently when called from the UI thread or any other 246 | /// thread. 247 | /// 248 | /// 249 | #else 250 | /// 251 | /// 252 | /// Generates a GIF of the currently-buffered screenshots and cursor movements. Returns 253 | /// if there are no screenshots currently buffered. No frames are erased by this call, and no new frames are 254 | /// buffered while this method is executing. 255 | /// 256 | /// 257 | /// This method is thread-safe and does not behave differently when called from the UI thread or any other 258 | /// thread. 259 | /// 260 | /// 261 | #endif 262 | public static byte[]? SaveGif() => SaveGifSharedResultMutex.GetResult(); 263 | 264 | private static byte[]? SaveGifCore() 265 | { 266 | if (isDisabled) return null; 267 | 268 | lock (FrameLock) 269 | { 270 | var frames = Frames.ToArray(); 271 | var framesByWindow = InfoByWindowHandle.Values.Select(i => i.GetFramesSnapshot()).ToList(); 272 | 273 | var renderer = new CompositionRenderer(frames, framesByWindow); 274 | if (renderer.FrameCount == 0) 275 | return null; 276 | 277 | if (bitmapDC is not { IsInvalid: false }) 278 | throw new InvalidOperationException("infoByWindowHandle should be empty if bitmapDC is not valid."); 279 | 280 | using var composition1 = new Composition(renderer.CompositionWidth, renderer.CompositionHeight, Frame.BitsPerPixel); 281 | using var composition2 = new Composition(renderer.CompositionWidth, renderer.CompositionHeight, Frame.BitsPerPixel); 282 | 283 | var frameSink = new FrameSink(renderer.CompositionWidth, renderer.CompositionHeight); 284 | 285 | var comparisonBuffer = composition1; 286 | var emitBuffer = composition2; 287 | 288 | var startingTimestamp = frames[frames.Length - renderer.FrameCount].Timestamp; 289 | var totalEmittedDelays = 0L; 290 | 291 | // First frame 292 | var needsGdiFlush = false; 293 | renderer.Compose(frameIndex: 0, emitBuffer, bitmapDC, ref needsGdiFlush, out var emitBufferNonEmptyArea); 294 | var emitBoundingRectangle = new UInt16Rectangle(0, 0, renderer.CompositionWidth, renderer.CompositionHeight); 295 | 296 | var comparisonBufferNonEmptyArea = default(UInt16Rectangle); 297 | 298 | for (var i = 1; i < renderer.FrameCount; i++) 299 | { 300 | comparisonBuffer.Clear( 301 | comparisonBufferNonEmptyArea.Left, 302 | comparisonBufferNonEmptyArea.Top, 303 | comparisonBufferNonEmptyArea.Width, 304 | comparisonBufferNonEmptyArea.Height, 305 | ref needsGdiFlush); 306 | 307 | renderer.Compose(i, comparisonBuffer, bitmapDC, ref needsGdiFlush, out comparisonBufferNonEmptyArea); 308 | 309 | var boundingRectangle = emitBufferNonEmptyArea.Union(comparisonBufferNonEmptyArea); 310 | 311 | // Required before accessing pixel data 312 | if (needsGdiFlush) 313 | { 314 | if (!PInvoke.GdiFlush()) throw new Win32Exception("GdiFlush failed."); 315 | needsGdiFlush = false; 316 | } 317 | 318 | DiffBoundsDetector.CropToChanges(emitBuffer, comparisonBuffer, ref boundingRectangle); 319 | 320 | if (boundingRectangle.IsEmpty) continue; 321 | 322 | var changeTimestamp = frames[i - renderer.FrameCount + frames.Length].Timestamp; 323 | var stopwatchTicksPerHundredthOfASecond = Stopwatch.Frequency / 100; 324 | var totalHundredthsOfASecond = (changeTimestamp - startingTimestamp) / stopwatchTicksPerHundredthOfASecond; 325 | 326 | frameSink.EmitFrame(emitBuffer, emitBoundingRectangle, (ushort)(totalHundredthsOfASecond - totalEmittedDelays)); 327 | totalEmittedDelays = totalHundredthsOfASecond; 328 | 329 | var nextBuffer = emitBuffer; 330 | emitBuffer = comparisonBuffer; 331 | comparisonBuffer = nextBuffer; 332 | 333 | var nextBufferNonEmptyArea = emitBufferNonEmptyArea; 334 | emitBufferNonEmptyArea = comparisonBufferNonEmptyArea; 335 | comparisonBufferNonEmptyArea = nextBufferNonEmptyArea; 336 | 337 | emitBoundingRectangle = boundingRectangle; 338 | } 339 | 340 | frameSink.EmitFrame(emitBuffer, emitBoundingRectangle, delayInHundredthsOfASecond: 400); 341 | 342 | return frameSink.End(); 343 | } 344 | } 345 | 346 | private static byte GetBitsPerPixel(uint paletteLength) 347 | { 348 | if (paletteLength > 256) 349 | throw new ArgumentOutOfRangeException(nameof(paletteLength), paletteLength, "Palette length must be no greater than 256."); 350 | 351 | #if NETFRAMEWORK 352 | // Distribution is expected to be heavily weighted towards large palettes 353 | if (paletteLength > 128) return 8; 354 | if (paletteLength > 64) return 7; 355 | if (paletteLength > 32) return 6; 356 | if (paletteLength > 16) return 5; 357 | if (paletteLength > 8) return 4; 358 | if (paletteLength > 4) return 3; 359 | if (paletteLength > 2) return 2; 360 | return 1; 361 | #else 362 | return paletteLength <= 2 ? (byte)1 : 363 | (byte)((sizeof(uint) * 8) - System.Numerics.BitOperations.LeadingZeroCount(paletteLength - 1)); 364 | #endif 365 | } 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/Native/DeleteDCSafeHandle.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.InteropServices; 3 | using Windows.Win32; 4 | using Windows.Win32.Graphics.Gdi; 5 | 6 | namespace Techsola.InstantReplay.Native; 7 | 8 | // Workaround for https://github.com/microsoft/CsWin32/issues/209 9 | internal sealed class DeleteDCSafeHandle : SafeHandle 10 | { 11 | public DeleteDCSafeHandle(IntPtr handle) : base(invalidHandleValue: IntPtr.Zero, ownsHandle: true) 12 | { 13 | SetHandle(handle); 14 | } 15 | 16 | public override bool IsInvalid => handle == IntPtr.Zero; 17 | 18 | protected override bool ReleaseHandle() 19 | { 20 | return (bool)PInvoke.DeleteDC((HDC)handle); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/Native/ERROR.cs: -------------------------------------------------------------------------------- 1 | namespace Techsola.InstantReplay.Native 2 | { 3 | /// 4 | /// 5 | /// 6 | internal enum ERROR : ushort 7 | { 8 | /// 9 | /// 10 | /// 11 | ACCESS_DENIED = 0x5, 12 | /// 13 | /// 14 | /// 15 | INVALID_PARAMETER = 0x57, 16 | /// 17 | /// 18 | /// 19 | INVALID_WINDOW_HANDLE = 0x578, 20 | /// 21 | /// 22 | /// 23 | DC_NOT_FOUND = 0x591, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/Native/UnownedHandle.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.InteropServices; 3 | 4 | namespace Techsola.InstantReplay.Native 5 | { 6 | internal sealed class UnownedHandle : SafeHandle 7 | { 8 | public UnownedHandle(IntPtr handle) 9 | : base(invalidHandleValue: IntPtr.Zero, ownsHandle: false) 10 | { 11 | SetHandle(handle); 12 | } 13 | 14 | public override bool IsInvalid => handle == IntPtr.Zero; 15 | 16 | protected override bool ReleaseHandle() => true; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/Native/WindowDeviceContextSafeHandle.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.InteropServices; 3 | using Windows.Win32; 4 | using Windows.Win32.Foundation; 5 | using Windows.Win32.Graphics.Gdi; 6 | 7 | namespace Techsola.InstantReplay.Native; 8 | 9 | // Workaround for https://github.com/microsoft/CsWin32/issues/209 10 | internal sealed class WindowDeviceContextSafeHandle : SafeHandle 11 | { 12 | public WindowDeviceContextSafeHandle(HWND hWnd, IntPtr handle) 13 | : base(invalidHandleValue: IntPtr.Zero, ownsHandle: true) 14 | { 15 | HWnd = hWnd; 16 | SetHandle(handle); 17 | } 18 | 19 | public HWND HWnd { get; } 20 | 21 | public override bool IsInvalid => handle == IntPtr.Zero; 22 | 23 | protected override bool ReleaseHandle() 24 | { 25 | return PInvoke.ReleaseDC(HWnd, (HDC)handle) == 1; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/NativeMethods.txt: -------------------------------------------------------------------------------- 1 | BitBlt 2 | BITMAP 3 | ClientToScreen 4 | CreateCompatibleDC 5 | CreateDIBSection 6 | DeleteDC 7 | DrawIconEx 8 | EnumWindows 9 | GdiFlush 10 | GetClientRect 11 | GetCursorInfo 12 | GetDC 13 | GetIconInfo 14 | GetObject 15 | GetWindowThreadProcessId 16 | IsWindowVisible 17 | ReleaseDC 18 | SelectObject 19 | SetLastError 20 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/Polyfill/SupportedOSPlatformAttribute.cs: -------------------------------------------------------------------------------- 1 | #if !NET5_0_OR_GREATER 2 | namespace System.Runtime.Versioning 3 | { 4 | [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Constructor | AttributeTargets.Enum | AttributeTargets.Event | AttributeTargets.Field | AttributeTargets.Method | AttributeTargets.Module | AttributeTargets.Property | AttributeTargets.Struct, AllowMultiple = true, Inherited = false)] 5 | internal sealed class SupportedOSPlatformAttribute : Attribute 6 | { 7 | public SupportedOSPlatformAttribute(string platformName) { } 8 | } 9 | } 10 | #endif 11 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/Polyfill/ValueTuple.cs: -------------------------------------------------------------------------------- 1 | #if NET35 2 | using System.Collections.Generic; 3 | 4 | namespace System 5 | { 6 | internal struct ValueTuple : IEquatable<(T1, T2)> 7 | { 8 | public T1 Item1; 9 | public T2 Item2; 10 | 11 | public ValueTuple(T1 item1, T2 item2) 12 | { 13 | Item1 = item1; 14 | Item2 = item2; 15 | } 16 | 17 | public override bool Equals(object? obj) 18 | { 19 | return obj is ValueTuple tuple && Equals(tuple); 20 | } 21 | 22 | public bool Equals((T1, T2) other) 23 | { 24 | return EqualityComparer.Default.Equals(Item1, other.Item1) && 25 | EqualityComparer.Default.Equals(Item2, other.Item2); 26 | } 27 | 28 | public override int GetHashCode() 29 | { 30 | var hashCode = -1030903623; 31 | hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Item1); 32 | hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Item2); 33 | return hashCode; 34 | } 35 | } 36 | 37 | internal struct ValueTuple : IEquatable<(T1, T2, T3)> 38 | { 39 | public T1 Item1; 40 | public T2 Item2; 41 | public T3 Item3; 42 | 43 | public ValueTuple(T1 item1, T2 item2, T3 item3) 44 | { 45 | Item1 = item1; 46 | Item2 = item2; 47 | Item3 = item3; 48 | } 49 | 50 | public override bool Equals(object? obj) 51 | { 52 | return obj is ValueTuple tuple && Equals(tuple); 53 | } 54 | 55 | public bool Equals((T1, T2, T3) other) 56 | { 57 | return EqualityComparer.Default.Equals(Item1, other.Item1) && 58 | EqualityComparer.Default.Equals(Item2, other.Item2) && 59 | EqualityComparer.Default.Equals(Item3, other.Item3); 60 | } 61 | 62 | public override int GetHashCode() 63 | { 64 | var hashCode = 341329424; 65 | hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Item1); 66 | hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Item2); 67 | hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Item3); 68 | return hashCode; 69 | } 70 | } 71 | } 72 | 73 | namespace System.Runtime.CompilerServices 74 | { 75 | [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue | AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Event)] 76 | internal sealed class TupleElementNamesAttribute : Attribute 77 | { 78 | public TupleElementNamesAttribute(string[]? transformNames) { } 79 | } 80 | } 81 | #endif 82 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/SharedResultMutex.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | 4 | namespace Techsola.InstantReplay 5 | { 6 | internal sealed class SharedResultMutex 7 | { 8 | private readonly Func resultFactory; 9 | private volatile BasicCompletionSource? resultSource; 10 | 11 | /// 12 | /// The result factory is never invoked concurrently by the same instance. 13 | /// 14 | public SharedResultMutex(Func resultFactory) 15 | { 16 | this.resultFactory = resultFactory; 17 | } 18 | 19 | /// 20 | /// 21 | /// If no other thread is currently getting the result, invokes the result factory and returns the result. 22 | /// Otherwise, blocks until the other thread is finished getting the result and then returns the same result as 23 | /// the other thread. 24 | /// 25 | /// 26 | /// The result factory is never invoked concurrently by the same instance. 27 | /// 28 | /// 29 | public T GetResult() 30 | { 31 | var (resultSource, didCreateSource) = GetResultSource(); 32 | 33 | if (!didCreateSource) return resultSource.GetResult(); 34 | 35 | try 36 | { 37 | try 38 | { 39 | var result = resultFactory.Invoke(); 40 | resultSource.SetResult(result); 41 | return result; 42 | } 43 | catch (Exception ex) when (resultSource.SetExceptionAndReturnFalse(ex)) // Use exception filter so that the stack information isn't lost on net35 44 | { 45 | throw; // Never hit because of the exception filter, but the compiler doesn't know that. 46 | } 47 | } 48 | finally 49 | { 50 | this.resultSource = null; // Volatile write 51 | } 52 | } 53 | 54 | private (BasicCompletionSource Source, bool DidCreateSource) GetResultSource() 55 | { 56 | var resultSource = this.resultSource; // Volatile read 57 | if (resultSource is null) 58 | { 59 | var newInvocationResult = new BasicCompletionSource(); 60 | 61 | resultSource = Interlocked.CompareExchange(ref this.resultSource, newInvocationResult, null); 62 | if (resultSource is null) 63 | return (newInvocationResult, DidCreateSource: true); 64 | 65 | newInvocationResult.Dispose(); 66 | } 67 | 68 | return (resultSource, DidCreateSource: false); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/Techsola.InstantReplay.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net35;net48;net8.0-windows 5 | true 6 | true 7 | true 8 | ..\Techsola.InstantReplay.snk 9 | 10 | 1.0.0 11 | Technology Solutions Associates 12 | Copyright © 2020–2024 Technology Solutions Associates 13 | MIT 14 | https://github.com/Techsola/InstantReplay 15 | https://github.com/Techsola/InstantReplay 16 | git 17 | animated GIF capture screenshot record windows desktop application UI diagnostic error crash report 18 | Produces a GIF on demand of the last ten seconds of a Windows desktop app’s user interface. Useful for error reports. 19 | true 20 | true 21 | snupkg 22 | 23 | 24 | $(NoWarn);PInvoke009 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/UInt16Rectangle.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Techsola.InstantReplay 4 | { 5 | internal struct UInt16Rectangle 6 | { 7 | public UInt16Rectangle(ushort left, ushort top, ushort width, ushort height) 8 | { 9 | Left = left; 10 | Top = top; 11 | Width = width; 12 | Height = height; 13 | } 14 | 15 | public ushort Left { get; set; } 16 | public ushort Top { get; set; } 17 | public ushort Width { get; set; } 18 | public ushort Height { get; set; } 19 | 20 | public bool IsEmpty => Width == 0 || Height == 0; 21 | 22 | public override string ToString() 23 | { 24 | return $"Left = {Left}, Top = {Top}, Width = {Width}, Height = {Height}"; 25 | } 26 | 27 | public UInt16Rectangle Union(UInt16Rectangle other) 28 | { 29 | if (IsEmpty) return other; 30 | if (other.IsEmpty) return this; 31 | 32 | var left = Math.Min(Left, other.Left); 33 | var top = Math.Min(Top, other.Top); 34 | var right = Math.Max(Left + Width, other.Left + other.Width); 35 | var bottom = Math.Max(Top + Height, other.Top + other.Height); 36 | 37 | return new(left, top, (ushort)(right - left), (ushort)(bottom - top)); 38 | } 39 | 40 | public UInt16Rectangle Intersect(UInt16Rectangle other) 41 | { 42 | var left = Math.Max(Left, other.Left); 43 | var top = Math.Max(Top, other.Top); 44 | var right = Math.Min(Left + Width, other.Left + other.Width); 45 | var bottom = Math.Min(Top + Height, other.Top + other.Height); 46 | 47 | if (right < left || bottom < top) return default; 48 | 49 | return new(left, top, (ushort)(right - left), (ushort)(bottom - top)); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/WindowEnumerator.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Windows.Win32; 3 | using Windows.Win32.Foundation; 4 | using Windows.Win32.UI.WindowsAndMessaging; 5 | 6 | namespace Techsola.InstantReplay 7 | { 8 | internal sealed class WindowEnumerator 9 | { 10 | private readonly uint currentProcessId; 11 | private readonly List list = new(); 12 | private readonly WNDENUMPROC callback; 13 | 14 | public WindowEnumerator() 15 | { 16 | callback = EnumWindowsCallback; 17 | 18 | #if !NETFRAMEWORK 19 | currentProcessId = (uint)System.Environment.ProcessId; 20 | #else 21 | currentProcessId = (uint)System.Diagnostics.Process.GetCurrentProcess().Id; 22 | #endif 23 | } 24 | 25 | public HWND[] GetCurrentWindowHandlesInZOrder() 26 | { 27 | PInvoke.EnumWindows(callback, lParam: default); 28 | 29 | #if !NET35 30 | if (list.Count == 0) return System.Array.Empty(); 31 | #endif 32 | 33 | var array = list.ToArray(); 34 | list.Clear(); 35 | return array; 36 | } 37 | 38 | private BOOL EnumWindowsCallback(HWND hWnd, LPARAM lParam) 39 | { 40 | var processId = default(uint); 41 | unsafe { _ = PInvoke.GetWindowThreadProcessId(hWnd, &processId); } 42 | if (processId == currentProcessId) list.Add(hWnd); 43 | return true; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/WindowMetrics.cs: -------------------------------------------------------------------------------- 1 | namespace Techsola.InstantReplay 2 | { 3 | partial class InstantReplayCamera 4 | { 5 | private readonly struct WindowMetrics 6 | { 7 | public readonly int ClientLeft; 8 | public readonly int ClientTop; 9 | public readonly int ClientWidth; 10 | public readonly int ClientHeight; 11 | 12 | public WindowMetrics(int clientLeft, int clientTop, int clientWidth, int clientHeight) 13 | { 14 | ClientLeft = clientLeft; 15 | ClientTop = clientTop; 16 | ClientWidth = clientWidth; 17 | ClientHeight = clientHeight; 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Techsola.InstantReplay/WuQuantizer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace Techsola.InstantReplay 5 | { 6 | // If the need arises to improve the chosen palette further, investigate the WSM-WU method as detailed in 7 | // https://arxiv.org/pdf/1101.0395.pdf. (Run Wu's algorithm to initialize cluster centers, then run the Weighted 8 | // Sort-Means algorithm.) Also consider quantizing in CIELAB color space instead of RGB. 9 | 10 | /// 11 | /// Implements by Xiaolin Wu. 12 | /// 13 | internal sealed class WuQuantizer 14 | { 15 | private const int MaxColorCount = 256; 16 | private const int HistogramChannelSizeLog2 = 5; 17 | private const int HistogramChannelSize = 1 << HistogramChannelSizeLog2; 18 | private const int ChannelIndexShift = 8 - HistogramChannelSizeLog2; 19 | 20 | private readonly MomentStatistics[,,] moments = new MomentStatistics[HistogramChannelSize + 1, HistogramChannelSize + 1, HistogramChannelSize + 1]; 21 | private readonly byte[,,] tag = new byte[HistogramChannelSize + 1, HistogramChannelSize + 1, HistogramChannelSize + 1]; 22 | 23 | // Referenced http://inis.jinr.ru/sl/vol1/CMC/Graphics_Gems_2,ed_J.Arvo.pdf and 24 | // https://github.com/JeremyAnsel/JeremyAnsel.ColorQuant/blob/a025932f7ec361337aaab3057608ed0f71e4e781/JeremyAnsel.ColorQuant/JeremyAnsel.ColorQuant/WuColorQuantizer.cs 25 | // to help figure out what was going on. 26 | 27 | public void Quantize( 28 | ColorEnumerable sourceImage, 29 | (byte R, byte G, byte B)[] paletteBuffer, 30 | out uint paletteLength, 31 | byte[] indexedImageBuffer, 32 | out uint indexedImageLength) 33 | { 34 | if (paletteBuffer.Length != MaxColorCount) 35 | throw new ArgumentException($"Palette buffer must be equal to the maximum color count {MaxColorCount}."); 36 | 37 | InitializeAs3DHistogram(sourceImage); 38 | 39 | ComputeCumulativeMoments(); 40 | 41 | var cubes = Partition(); 42 | 43 | OutputPalette(cubes, paletteBuffer, out paletteLength); 44 | OutputIndexedPixels(sourceImage, cubes, indexedImageBuffer, out indexedImageLength); 45 | } 46 | 47 | private void OutputIndexedPixels(ColorEnumerable sourceImage, Box[] cubes, byte[] indexedImageBuffer, out uint indexedImageLength) 48 | { 49 | for (var paletteIndex = 0; paletteIndex < cubes.Length; paletteIndex++) 50 | { 51 | ref readonly var cube = ref cubes[paletteIndex]; 52 | 53 | for (var channel1 = cube.Channel1.Bottom + 1; channel1 <= cube.Channel1.Top; channel1++) 54 | for (var channel2 = cube.Channel2.Bottom + 1; channel2 <= cube.Channel2.Top; channel2++) 55 | for (var channel3 = cube.Channel3.Bottom + 1; channel3 <= cube.Channel3.Top; channel3++) 56 | tag[channel1, channel2, channel3] = (byte)paletteIndex; 57 | } 58 | 59 | var i = 0u; 60 | foreach (var pixel in sourceImage) 61 | { 62 | indexedImageBuffer[i] = tag[ 63 | (pixel.Channel1 >> ChannelIndexShift) + 1, 64 | (pixel.Channel2 >> ChannelIndexShift) + 1, 65 | (pixel.Channel3 >> ChannelIndexShift) + 1]; 66 | i++; 67 | } 68 | 69 | indexedImageLength = i; 70 | } 71 | 72 | private void OutputPalette(Box[] cubes, (byte R, byte G, byte B)[] paletteBuffer, out uint paletteLength) 73 | { 74 | paletteLength = (uint)cubes.Length; 75 | 76 | for (var i = 0; i < cubes.Length; i++) 77 | { 78 | var volume = GetVolume(in cubes[i]); 79 | 80 | paletteBuffer[i] = ( 81 | (byte)(volume.Channel1TimesDensity / volume.Density), 82 | (byte)(volume.Channel2TimesDensity / volume.Density), 83 | (byte)(volume.Channel3TimesDensity / volume.Density)); 84 | } 85 | } 86 | 87 | private Box[] Partition() 88 | { 89 | var cubes = new List(capacity: MaxColorCount) 90 | { 91 | new() 92 | { 93 | Channel1 = { Top = HistogramChannelSize }, 94 | Channel2 = { Top = HistogramChannelSize }, 95 | Channel3 = { Top = HistogramChannelSize }, 96 | }, 97 | }; 98 | 99 | var variances = new List(capacity: MaxColorCount) { 0 }; 100 | var next = 0; 101 | 102 | while (cubes.Count < MaxColorCount) 103 | { 104 | if (Cut(cubes[next]) is var (newBottom, newTop)) 105 | { 106 | cubes[next] = newBottom; 107 | variances[next] = newBottom.Volume > 1 ? GetWeightedVariance(in newBottom) : 0; 108 | 109 | cubes.Add(newTop); 110 | variances.Add(newTop.Volume > 1 ? GetWeightedVariance(in newTop) : 0); 111 | } 112 | else 113 | { 114 | variances[next] = 0; // Don't try to split this box again 115 | } 116 | 117 | next = 0; 118 | var maxVariance = variances[0]; 119 | 120 | for (var k = 1; k < variances.Count; k++) 121 | { 122 | if (maxVariance < variances[k]) 123 | { 124 | maxVariance = variances[k]; 125 | next = k; 126 | } 127 | } 128 | 129 | if (maxVariance <= 0) break; 130 | } 131 | 132 | return cubes.ToArray(); 133 | } 134 | 135 | private void InitializeAs3DHistogram(ColorEnumerable sourceImage) 136 | { 137 | Array.Clear(moments, 0, moments.Length); 138 | 139 | foreach (var pixel in sourceImage) 140 | { 141 | ref var latticePoint = ref moments[ 142 | (pixel.Channel1 >> ChannelIndexShift) + 1, 143 | (pixel.Channel2 >> ChannelIndexShift) + 1, 144 | (pixel.Channel3 >> ChannelIndexShift) + 1]; 145 | 146 | latticePoint.Density++; 147 | latticePoint.Channel1TimesDensity += pixel.Channel1; 148 | latticePoint.Channel2TimesDensity += pixel.Channel2; 149 | latticePoint.Channel3TimesDensity += pixel.Channel3; 150 | latticePoint.MagnitudeSquaredTimesDensity += (pixel.Channel1 * pixel.Channel1) + (pixel.Channel2 * pixel.Channel2) + (pixel.Channel3 * pixel.Channel3); 151 | } 152 | } 153 | 154 | private void ComputeCumulativeMoments() 155 | { 156 | var areaByChannel3 = new MomentStatistics[HistogramChannelSize + 1]; 157 | 158 | for (var channel1 = 1; channel1 <= HistogramChannelSize; channel1++) 159 | { 160 | Array.Clear(areaByChannel3, 0, areaByChannel3.Length); 161 | 162 | for (var channel2 = 1; channel2 <= HistogramChannelSize; channel2++) 163 | { 164 | var line = default(MomentStatistics); 165 | 166 | for (var channel3 = 1; channel3 <= HistogramChannelSize; channel3++) 167 | { 168 | ref var latticePoint = ref moments[channel1, channel2, channel3]; 169 | line += latticePoint; 170 | 171 | ref var area = ref areaByChannel3[channel3]; 172 | area += line; 173 | 174 | latticePoint = moments[channel1 - 1, channel2, channel3] + area; 175 | } 176 | } 177 | } 178 | } 179 | 180 | private MomentStatistics GetVolume(in Box cube) 181 | { 182 | return 183 | moments[cube.Channel1.Top, cube.Channel2.Top, cube.Channel3.Top] 184 | - moments[cube.Channel1.Top, cube.Channel2.Top, cube.Channel3.Bottom] 185 | - moments[cube.Channel1.Top, cube.Channel2.Bottom, cube.Channel3.Top] 186 | + moments[cube.Channel1.Top, cube.Channel2.Bottom, cube.Channel3.Bottom] 187 | - moments[cube.Channel1.Bottom, cube.Channel2.Top, cube.Channel3.Top] 188 | + moments[cube.Channel1.Bottom, cube.Channel2.Top, cube.Channel3.Bottom] 189 | + moments[cube.Channel1.Bottom, cube.Channel2.Bottom, cube.Channel3.Top] 190 | - moments[cube.Channel1.Bottom, cube.Channel2.Bottom, cube.Channel3.Bottom]; 191 | } 192 | 193 | private MomentStatistics GetBottom(in Box cube, Direction direction) 194 | { 195 | return direction switch 196 | { 197 | Direction.Channel1 => 198 | -moments[cube.Channel1.Bottom, cube.Channel2.Top, cube.Channel3.Top] 199 | + moments[cube.Channel1.Bottom, cube.Channel2.Top, cube.Channel3.Bottom] 200 | + moments[cube.Channel1.Bottom, cube.Channel2.Bottom, cube.Channel3.Top] 201 | - moments[cube.Channel1.Bottom, cube.Channel2.Bottom, cube.Channel3.Bottom], 202 | 203 | Direction.Channel2 => 204 | -moments[cube.Channel1.Top, cube.Channel2.Bottom, cube.Channel3.Top] 205 | + moments[cube.Channel1.Top, cube.Channel2.Bottom, cube.Channel3.Bottom] 206 | + moments[cube.Channel1.Bottom, cube.Channel2.Bottom, cube.Channel3.Top] 207 | - moments[cube.Channel1.Bottom, cube.Channel2.Bottom, cube.Channel3.Bottom], 208 | 209 | Direction.Channel3 => 210 | -moments[cube.Channel1.Top, cube.Channel2.Top, cube.Channel3.Bottom] 211 | + moments[cube.Channel1.Top, cube.Channel2.Bottom, cube.Channel3.Bottom] 212 | + moments[cube.Channel1.Bottom, cube.Channel2.Top, cube.Channel3.Bottom] 213 | - moments[cube.Channel1.Bottom, cube.Channel2.Bottom, cube.Channel3.Bottom], 214 | }; 215 | } 216 | 217 | private MomentStatistics GetTop(in Box cube, Direction direction, int position) 218 | { 219 | return direction switch 220 | { 221 | Direction.Channel1 => 222 | moments[position, cube.Channel2.Top, cube.Channel3.Top] 223 | - moments[position, cube.Channel2.Top, cube.Channel3.Bottom] 224 | - moments[position, cube.Channel2.Bottom, cube.Channel3.Top] 225 | + moments[position, cube.Channel2.Bottom, cube.Channel3.Bottom], 226 | 227 | Direction.Channel2 => 228 | moments[cube.Channel1.Top, position, cube.Channel3.Top] 229 | - moments[cube.Channel1.Top, position, cube.Channel3.Bottom] 230 | - moments[cube.Channel1.Bottom, position, cube.Channel3.Top] 231 | + moments[cube.Channel1.Bottom, position, cube.Channel3.Bottom], 232 | 233 | Direction.Channel3 => 234 | moments[cube.Channel1.Top, cube.Channel2.Top, position] 235 | - moments[cube.Channel1.Top, cube.Channel2.Bottom, position] 236 | - moments[cube.Channel1.Bottom, cube.Channel2.Top, position] 237 | + moments[cube.Channel1.Bottom, cube.Channel2.Bottom, position], 238 | }; 239 | } 240 | 241 | private float GetWeightedVariance(in Box cube) 242 | { 243 | var volume = GetVolume(in cube); 244 | 245 | return volume.MagnitudeSquaredTimesDensity - volume.GetSumOfChannelsSquaredOverDensity(); 246 | } 247 | 248 | private (float Max, int Cut) Maximize(in Box cube, Direction direction, int first, int last, in MomentStatistics whole) 249 | { 250 | var bottom = GetBottom(in cube, direction); 251 | 252 | var max = 0f; 253 | var cut = -1; 254 | 255 | for (var position = first; position < last; position++) 256 | { 257 | var half = bottom + GetTop(in cube, direction, position); 258 | if (half.Density == 0) continue; 259 | 260 | var temp = half.GetSumOfChannelsSquaredOverDensity(); 261 | 262 | half = whole - half; 263 | if (half.Density == 0) continue; 264 | 265 | temp += half.GetSumOfChannelsSquaredOverDensity(); 266 | 267 | if (max < temp) 268 | { 269 | max = temp; 270 | cut = position; 271 | } 272 | } 273 | 274 | return (max, cut); 275 | } 276 | 277 | private (Box Bottom, Box Top)? Cut(Box cube) 278 | { 279 | var whole = GetVolume(in cube); 280 | 281 | var channel1 = Maximize(in cube, Direction.Channel1, first: cube.Channel1.Bottom + 1, last: cube.Channel1.Top, in whole); 282 | var channel2 = Maximize(in cube, Direction.Channel2, first: cube.Channel2.Bottom + 1, last: cube.Channel2.Top, in whole); 283 | var channel3 = Maximize(in cube, Direction.Channel3, first: cube.Channel3.Bottom + 1, last: cube.Channel3.Top, in whole); 284 | 285 | var newCube = default(Box); 286 | 287 | newCube.Channel1.Top = cube.Channel1.Top; 288 | newCube.Channel2.Top = cube.Channel2.Top; 289 | newCube.Channel3.Top = cube.Channel3.Top; 290 | 291 | if (channel1.Max >= channel2.Max && channel1.Max >= channel3.Max) 292 | { 293 | if (channel1.Cut < 0) return null; 294 | 295 | newCube.Channel1.Bottom = cube.Channel1.Top = channel1.Cut; 296 | newCube.Channel2.Bottom = cube.Channel2.Bottom; 297 | newCube.Channel3.Bottom = cube.Channel3.Bottom; 298 | } 299 | else if (channel2.Max >= channel1.Max && channel2.Max >= channel3.Max) 300 | { 301 | newCube.Channel1.Bottom = cube.Channel1.Bottom; 302 | newCube.Channel2.Bottom = cube.Channel2.Top = channel2.Cut; 303 | newCube.Channel3.Bottom = cube.Channel3.Bottom; 304 | } 305 | else 306 | { 307 | newCube.Channel1.Bottom = cube.Channel1.Bottom; 308 | newCube.Channel2.Bottom = cube.Channel2.Bottom; 309 | newCube.Channel3.Bottom = cube.Channel3.Top = channel3.Cut; 310 | } 311 | 312 | cube.CalculateVolume(); 313 | newCube.CalculateVolume(); 314 | 315 | return (Bottom: cube, Top: newCube); 316 | } 317 | 318 | private enum Direction 319 | { 320 | Channel1, 321 | Channel2, 322 | Channel3, 323 | } 324 | 325 | private struct MomentStatistics 326 | { 327 | /// P(c) 328 | public int Density; 329 | /// Channel1 × P(c) 330 | public int Channel1TimesDensity; 331 | /// Channel2 × P(c) 332 | public int Channel2TimesDensity; 333 | /// Channel3 × P(c) 334 | public int Channel3TimesDensity; 335 | /// c² × P(c) 336 | public float MagnitudeSquaredTimesDensity; 337 | 338 | public float GetSumOfChannelsSquaredOverDensity() 339 | { 340 | return ( 341 | (float)Channel1TimesDensity * Channel1TimesDensity 342 | + (float)Channel2TimesDensity * Channel2TimesDensity 343 | + (float)Channel3TimesDensity * Channel3TimesDensity) 344 | / Density; 345 | } 346 | 347 | public static MomentStatistics operator +(in MomentStatistics left, in MomentStatistics right) 348 | { 349 | return new() 350 | { 351 | Density = left.Density + right.Density, 352 | Channel1TimesDensity = left.Channel1TimesDensity + right.Channel1TimesDensity, 353 | Channel2TimesDensity = left.Channel2TimesDensity + right.Channel2TimesDensity, 354 | Channel3TimesDensity = left.Channel3TimesDensity + right.Channel3TimesDensity, 355 | MagnitudeSquaredTimesDensity = left.MagnitudeSquaredTimesDensity + right.MagnitudeSquaredTimesDensity, 356 | }; 357 | } 358 | 359 | public static MomentStatistics operator -(in MomentStatistics left, in MomentStatistics right) 360 | { 361 | return new() 362 | { 363 | Density = left.Density - right.Density, 364 | Channel1TimesDensity = left.Channel1TimesDensity - right.Channel1TimesDensity, 365 | Channel2TimesDensity = left.Channel2TimesDensity - right.Channel2TimesDensity, 366 | Channel3TimesDensity = left.Channel3TimesDensity - right.Channel3TimesDensity, 367 | MagnitudeSquaredTimesDensity = left.MagnitudeSquaredTimesDensity - right.MagnitudeSquaredTimesDensity, 368 | }; 369 | } 370 | 371 | public static MomentStatistics operator -(in MomentStatistics statistics) 372 | { 373 | return new() 374 | { 375 | Density = -statistics.Density, 376 | Channel1TimesDensity = -statistics.Channel1TimesDensity, 377 | Channel2TimesDensity = -statistics.Channel2TimesDensity, 378 | Channel3TimesDensity = -statistics.Channel3TimesDensity, 379 | MagnitudeSquaredTimesDensity = -statistics.MagnitudeSquaredTimesDensity, 380 | }; 381 | } 382 | } 383 | 384 | private struct Range 385 | { 386 | /// Exclusive minimum. 387 | public int Bottom; 388 | /// Inclusive maximum. 389 | public int Top; 390 | 391 | public int Length => Top - Bottom; 392 | } 393 | 394 | private struct Box 395 | { 396 | public Range Channel1; 397 | public Range Channel2; 398 | public Range Channel3; 399 | public int Volume; 400 | 401 | public void CalculateVolume() 402 | { 403 | Volume = Channel1.Length * Channel2.Length * Channel3.Length; 404 | } 405 | } 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /src/TestWinFormsApp/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using System.Windows.Forms; 6 | using Techsola.InstantReplay; 7 | 8 | namespace TestWinFormsApp 9 | { 10 | public static class Program 11 | { 12 | [STAThread] 13 | public static void Main() 14 | { 15 | InstantReplayCamera.Start( 16 | reportBackgroundException: ex => MessageBox.Show( 17 | ex.ToString(), 18 | "Unhandled exception in Techsola.InstantReplay", 19 | MessageBoxButtons.OK, 20 | MessageBoxIcon.Error)); 21 | 22 | Application.SetCompatibleTextRenderingDefault(false); 23 | Application.EnableVisualStyles(); 24 | 25 | using var mainForm = new Form 26 | { 27 | Text = "Test Windows Forms application", 28 | Controls = 29 | { 30 | new FlowLayoutPanel 31 | { 32 | Dock = DockStyle.Fill, 33 | Controls = 34 | { 35 | CreateButton("File dialog on new thread", ShowDialogOnNewThread), 36 | CreateButton("Save current GIF to desktop", Save), 37 | new ComboBox 38 | { 39 | Items = { "A", "B", "C", "D", "E", "F", "G" }, 40 | }, 41 | }, 42 | }, 43 | }, 44 | Padding = new(24, 16, 24, 16), 45 | }; 46 | 47 | Application.Run(mainForm); 48 | } 49 | 50 | private static void ShowDialogOnNewThread() 51 | { 52 | var thread = new Thread(() => 53 | { 54 | using var dialog = new OpenFileDialog(); 55 | 56 | dialog.ShowDialog(owner: null); 57 | }); 58 | 59 | thread.SetApartmentState(ApartmentState.STA); 60 | thread.Start(); 61 | } 62 | 63 | private static void Save() 64 | { 65 | Task.Run(async () => 66 | { 67 | if (InstantReplayCamera.SaveGif() is { } bytes) 68 | { 69 | var directoryPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), "Techsola.InstantReplay"); 70 | 71 | Directory.CreateDirectory(directoryPath); 72 | 73 | var filePath = Path.Combine(directoryPath, $"{DateTime.Now:yyyy-MM-dd HH.mm.ss}.gif"); 74 | using var stream = File.Create(filePath); 75 | 76 | await stream.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); 77 | } 78 | }); 79 | } 80 | 81 | private static Button CreateButton(string text, Action onClick) 82 | { 83 | var button = new Button 84 | { 85 | AutoSize = true, 86 | AutoSizeMode = AutoSizeMode.GrowAndShrink, 87 | FlatStyle = FlatStyle.System, 88 | Text = text, 89 | }; 90 | 91 | button.Click += (_, _) => onClick(); 92 | 93 | return button; 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/TestWinFormsApp/TestWinFormsApp.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | WinExe 5 | net48 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | --------------------------------------------------------------------------------