├── .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 [](https://www.myget.org/feed/techsola/package/nuget/Techsola.InstantReplay "MyGet (prereleases)") [](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 |
--------------------------------------------------------------------------------