diff --git a/Documentation.html b/Documentation.html
index a01b6f3..6f8d6b2 100644
--- a/Documentation.html
+++ b/Documentation.html
@@ -130,6 +130,8 @@
Configuration
Frame Rate: Default is 30. Higher rates may increase file size and resource usage.
Recording Duration: Default is 60 seconds.
Downscale Video: Off by default. If enabled, the video will be downscaled to a maximum resolution (default: 1920x1080) if your game runs at a higher resolution. This helps reduce CPU usage and file size when recording on high-resolution displays. Enable this option in the inspector if you notice high CPU usage or stuttering during recording at large resolutions.
+ Render Cursor: Off by default. When enabled, the mouse cursor position will be rendered in the recorded video. This is useful for showing user interactions and click locations during bug reports. The cursor is drawn as a white arrow with a black outline for visibility against different backgrounds.
+ Capture Render Texture: Optional. When set, the recorder will capture from this specific render texture instead of the main screen. This is useful for recording from specific cameras, UI canvases, or other render targets. The recorder will automatically handle scaling if the render texture dimensions don't match the output resolution. Leave empty to record the main screen.
diff --git a/Runtime/Prefabs/BugReportingFormCanvas.prefab b/Runtime/Prefabs/BugReportingFormCanvas.prefab
index 64e925b..2e82cd8 100644
--- a/Runtime/Prefabs/BugReportingFormCanvas.prefab
+++ b/Runtime/Prefabs/BugReportingFormCanvas.prefab
@@ -32,7 +32,6 @@ RectTransform:
- {fileID: 977477087176976246}
- {fileID: 4000851359225435227}
m_Father: {fileID: 3630024249061363983}
- m_RootOrder: 9
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 1, y: 1}
@@ -118,7 +117,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 7066460814585062359}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
@@ -195,7 +193,6 @@ RectTransform:
m_Children:
- {fileID: 1687794342509650617}
m_Father: {fileID: 1503495149724826169}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
@@ -272,7 +269,6 @@ RectTransform:
m_Children:
- {fileID: 5559154134761285907}
m_Father: {fileID: 6900125880984329700}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
@@ -349,7 +345,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 6900125880984329700}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
@@ -497,7 +492,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 8830360361489452988}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
@@ -573,7 +567,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1503495149724826169}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -653,7 +646,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3571735674788685447}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
@@ -729,7 +721,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 2032552138566355961}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 1, y: 1}
@@ -865,7 +856,6 @@ RectTransform:
- {fileID: 7066460814585062359}
- {fileID: 7996142808180909739}
m_Father: {fileID: 2032552138566355961}
- m_RootOrder: 5
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
@@ -951,7 +941,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 485512187738098592}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -1086,7 +1075,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 4993008937618779998}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -1221,7 +1209,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1741720219296545283}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -1356,7 +1343,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1627904781333639061}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -1436,7 +1422,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 2032552138566355961}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 1, y: 1}
@@ -1576,7 +1561,6 @@ RectTransform:
m_Children:
- {fileID: 4993008937618779998}
m_Father: {fileID: 2032552138566355961}
- m_RootOrder: 2
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 1, y: 1}
@@ -1752,7 +1736,6 @@ RectTransform:
m_Children:
- {fileID: 3630024247477140643}
m_Father: {fileID: 3630024248915368463}
- m_RootOrder: 2
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 1, y: 0}
m_AnchorMax: {x: 1, y: 0}
@@ -1872,7 +1855,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3630024247434959543}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -2010,7 +1992,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3630024247696638879}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -2174,7 +2155,6 @@ RectTransform:
- {fileID: 3630024248915368463}
- {fileID: 2032552138566355961}
m_Father: {fileID: 0}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -2200,6 +2180,7 @@ Canvas:
m_SortingBucketNormalizedSize: 0
m_VertexColorAlwaysGammaSpace: 0
m_AdditionalShaderChannelsFlag: 25
+ m_UpdateRectTransformForStandalone: 0
m_SortingLayerID: 0
m_SortingOrder: 32767
m_TargetDisplay: 0
@@ -2255,23 +2236,25 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 7901aaefc5f754eecb87f4d46d21fcf7, type: 3}
m_Name:
m_EditorClassIdentifier:
- bugReportPanel: {fileID: 3630024249061363980}
- descriptionField: {fileID: 3630024249523897025}
- stepsField: {fileID: 3630024249440770398}
+ BugReportPanel: {fileID: 3630024249061363980}
+ DescriptionField: {fileID: 3630024249523897025}
+ StepsField: {fileID: 3630024249440770398}
IncludeVideoToggle: {fileID: 827000258257465722}
IncludeScreenshotToggle: {fileID: 4530607169642399183}
IncludePlayerLogToggle: {fileID: 522816198355007551}
- submitButton: {fileID: 3630024248266512409}
- closeButton: {fileID: 6839878271182632936}
- messagePanel: {fileID: 3630024248915368460}
- messagePanelUI: {fileID: 3630024248915368456}
- reportSubmittedUI: {fileID: 8165929657888516934}
- submitEndpoint: https://app.betahub.io
- projectID: pr-5287510306
- authToken: tkn-15e6fbc1470613d5cfd2199edbde52157379b8c6dcd365441eabf8fea62d76a7
- shortcutKey: 293
- includePlayerLog: 1
- includeVideo: 1
+ SubmitButton: {fileID: 3630024248266512409}
+ CloseButton: {fileID: 6839878271182632936}
+ MessagePanel: {fileID: 3630024248915368460}
+ MessagePanelUI: {fileID: 3630024248915368456}
+ MediaUploadType: 0
+ ReportSubmittedUI: {fileID: 8165929657888516934}
+ SubmitEndpoint: https://app.betahub.io
+ ProjectID: pr-5287510306
+ AuthToken: tkn-15e6fbc1470613d5cfd2199edbde52157379b8c6dcd365441eabf8fea62d76a7
+ DefaultEmailAddress:
+ ShortcutKey: 293
+ IncludePlayerLog: 1
+ IncludeVideo: 1
OnBugReportWindowShown:
m_PersistentCalls:
m_Calls: []
@@ -2304,8 +2287,11 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: d140507fc9ceb4e6ea7621a4a80a0d24, type: 3}
m_Name:
m_EditorClassIdentifier:
- frameRate: 30
+ FrameRate: 30
RecordingDuration: 60
+ DownscaleVideo: 1
+ MaxVideoHeight: 768
+ MaxVideoWidth: 1280
DebugMode: 0
--- !u!114 &7122726013767127902
MonoBehaviour:
@@ -2374,7 +2360,6 @@ RectTransform:
- {fileID: 3630024247595246889}
- {fileID: 3630024249088894872}
m_Father: {fileID: 3630024249440770397}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -2426,7 +2411,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3630024248915368463}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -2562,7 +2546,6 @@ RectTransform:
- {fileID: 3630024248099868723}
- {fileID: 3630024248145179427}
m_Father: {fileID: 3630024249523897028}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -2614,7 +2597,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3630024248266512410}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -2750,7 +2732,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3630024247824868509}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -2905,7 +2886,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3630024247824868509}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -3040,7 +3020,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3630024249061363983}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
@@ -3177,7 +3156,6 @@ RectTransform:
m_Children:
- {fileID: 3630024248052450034}
m_Father: {fileID: 3630024249061363983}
- m_RootOrder: 4
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 1, y: 0}
m_AnchorMax: {x: 1, y: 0}
@@ -3297,7 +3275,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3630024249061363983}
- m_RootOrder: 5
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
@@ -3436,7 +3413,6 @@ RectTransform:
- {fileID: 3630024249432119131}
- {fileID: 3630024247434959543}
m_Father: {fileID: 3630024247677609247}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
@@ -3537,7 +3513,6 @@ RectTransform:
- {fileID: 4184646509822323759}
- {fileID: 1503495149724826169}
m_Father: {fileID: 3630024247677609247}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
@@ -3613,7 +3588,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3630024247696638879}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -3748,7 +3722,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3630024249061363983}
- m_RootOrder: 2
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 1}
m_AnchorMax: {x: 0.5, y: 1}
@@ -3883,7 +3856,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3630024248915368463}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -4020,7 +3992,6 @@ RectTransform:
m_Children:
- {fileID: 3630024247696638879}
m_Father: {fileID: 3630024249061363983}
- m_RootOrder: 3
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 1, y: 1}
@@ -4196,7 +4167,6 @@ RectTransform:
m_Children:
- {fileID: 3630024247824868509}
m_Father: {fileID: 3630024249061363983}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 1, y: 1}
@@ -4378,7 +4348,6 @@ RectTransform:
- {fileID: 6689157203348440904}
- {fileID: 6900125880984329700}
m_Father: {fileID: 3630024247677609247}
- m_RootOrder: 2
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
@@ -4470,7 +4439,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 852619800817139833}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
@@ -4546,7 +4514,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3713366576188347011}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -4684,7 +4651,6 @@ RectTransform:
- {fileID: 3571735674788685447}
- {fileID: 7344009242182697266}
m_Father: {fileID: 2032552138566355961}
- m_RootOrder: 6
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
@@ -4772,7 +4738,6 @@ RectTransform:
m_Children:
- {fileID: 7386148524240149116}
m_Father: {fileID: 3630024249061363983}
- m_RootOrder: 6
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 1, y: 1}
m_AnchorMax: {x: 1, y: 1}
@@ -4893,7 +4858,6 @@ RectTransform:
m_Children:
- {fileID: 3988553454410020858}
m_Father: {fileID: 1627904781333639061}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
@@ -4969,7 +4933,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 977477087176976246}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
@@ -5046,7 +5009,6 @@ RectTransform:
- {fileID: 852619800817139833}
- {fileID: 2793722129197531301}
m_Father: {fileID: 3630024249061363983}
- m_RootOrder: 8
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 1, y: 1}
@@ -5133,7 +5095,6 @@ RectTransform:
m_Children:
- {fileID: 7647189051541875379}
m_Father: {fileID: 4184646509822323759}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
@@ -5211,7 +5172,6 @@ RectTransform:
m_Children:
- {fileID: 5341077937392854438}
m_Father: {fileID: 2032552138566355961}
- m_RootOrder: 3
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 1, y: 0}
m_AnchorMax: {x: 1, y: 0}
@@ -5332,7 +5292,6 @@ RectTransform:
- {fileID: 8830360361489452988}
- {fileID: 3220442243219187897}
m_Father: {fileID: 3630024249061363983}
- m_RootOrder: 7
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 1, y: 1}
@@ -5419,7 +5378,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 6689157203348440904}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
@@ -5568,7 +5526,6 @@ RectTransform:
m_Children:
- {fileID: 1908213462890504975}
m_Father: {fileID: 6689157203348440904}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
@@ -5645,7 +5602,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 4993008937618779998}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -5800,7 +5756,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 4184646509822323759}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -5882,7 +5837,6 @@ RectTransform:
m_Children:
- {fileID: 1140490455101815007}
m_Father: {fileID: 2032552138566355961}
- m_RootOrder: 4
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 1, y: 0}
m_AnchorMax: {x: 1, y: 0}
@@ -6003,7 +5957,6 @@ RectTransform:
- {fileID: 8246874345483132745}
- {fileID: 5924505674562940352}
m_Father: {fileID: 300062612667332752}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
diff --git a/Runtime/Scripts/GameRecorder.cs b/Runtime/Scripts/GameRecorder.cs
index 238d29c..96f2d59 100644
--- a/Runtime/Scripts/GameRecorder.cs
+++ b/Runtime/Scripts/GameRecorder.cs
@@ -2,6 +2,8 @@
using System.Collections;
using System.IO;
using UnityEngine.Serialization;
+using Unity.Collections;
+using UnityEngine.Rendering;
namespace BetaHub
{
@@ -21,7 +23,24 @@ public class GameRecorder : MonoBehaviour
[Tooltip("The maximum width of the video. The video will be downscaled if the screen resolution is higher.")]
public int MaxVideoWidth = 1920;
- private Texture2D _screenShot;
+ [Tooltip("If enabled, renders the mouse cursor position in the recorded video.")]
+ public bool RenderCursor = false;
+
+ [Tooltip("If enabled, mirrors the video vertically (flips it upside down).")]
+ public bool MirrorVertically = false;
+
+ // set this to a render texture to capture a specific render texture instead of the screen
+ [HideInInspector]
+ public RenderTexture CaptureRenderTexture;
+
+ // Optimized: Use RenderTextures for efficient GPU-based capture
+ private RenderTexture _captureRT;
+ private RenderTexture _fullScreenRT; // For capturing full screen when downscaling
+ private byte[] _rgbConversionBuffer; // Reusable buffer for RGBA to RGB conversion
+
+ // GC Optimization: Reusable buffers to avoid allocations in capture loop
+ private byte[] _frameDataBuffer; // Reusable buffer for frame data from GPU
+ private byte[] _rgbaDataBuffer; // Reusable buffer for RGBA texture data
public bool IsRecording { get; private set; }
#if ENABLE_IL2CPP && !ENABLE_BETAHUB_FFMPEG
@@ -31,7 +50,6 @@ public class GameRecorder : MonoBehaviour
#endif
private VideoEncoder _videoEncoder;
- private TexturePainter _texturePainter;
private int _gameWidth;
private int _gameHeight;
@@ -55,9 +73,16 @@ void Start()
return;
#endif
- // Adjust the game resolution to be divisible by 2
- _gameWidth = Screen.width % 2 == 0 ? Screen.width : Screen.width - 1;
- _gameHeight = Screen.height % 2 == 0 ? Screen.height : Screen.height - 1;
+ // Adjust the game resolution to be divisible by 4
+ _gameWidth = Screen.width - (Screen.width % 4);
+ _gameHeight = Screen.height - (Screen.height % 4);
+
+ // if custom render texture is not set and the screen w and h is not divisible by 4, print a warning
+ if (CaptureRenderTexture == null && (_gameWidth % 4 != 0 || _gameHeight % 4 != 0))
+ {
+ UnityEngine.Debug.LogWarning("Current screen width and height are not divisible by 4. " +
+ "This may cause severe performance issues.");
+ }
// Determine target (output) resolution
_outputWidth = _gameWidth;
@@ -72,16 +97,32 @@ void Start()
_outputWidth = MaxVideoWidth;
_outputHeight = Mathf.RoundToInt(_outputWidth / aspect);
}
- // Ensure both dimensions are even
- _outputWidth -= _outputWidth % 2;
- _outputHeight -= _outputHeight % 2;
+ // Ensure both dimensions are multiples of 4 (for AsyncGPUReadback)
+ _outputWidth -= _outputWidth % 4;
+ _outputHeight -= _outputHeight % 4;
UnityEngine.Debug.Log($"Video is to be downscaled to {_outputWidth}x{_outputHeight}");
}
- // Create a Texture2D with the adjusted resolution
- _screenShot = new Texture2D(_gameWidth, _gameHeight, TextureFormat.RGB24, false);
- _texturePainter = new TexturePainter(_screenShot);
+ // Optimized: Create RenderTexture once at output resolution (handles downscaling automatically)
+ _captureRT = new RenderTexture(_outputWidth, _outputHeight, 0, RenderTextureFormat.ARGB32);
+ _captureRT.Create();
+
+ // If we're downscaling, we need a full-screen texture to capture from first
+ if (DownscaleVideo && (_gameWidth != _outputWidth || _gameHeight != _outputHeight))
+ {
+ _fullScreenRT = new RenderTexture(_gameWidth, _gameHeight, 0, RenderTextureFormat.ARGB32);
+ _fullScreenRT.Create();
+ }
+
+ // Initialize RGB conversion buffer
+ _rgbConversionBuffer = new byte[_outputWidth * _outputHeight * 3]; // RGB24 format
+
+ // GC Optimization: Initialize reusable buffers and textures
+ int expectedFrameSize = _outputWidth * _outputHeight * 4; // RGBA32 format
+ _frameDataBuffer = new byte[expectedFrameSize];
+ _rgbaDataBuffer = new byte[expectedFrameSize];
+
IsRecording = false;
string outputDirectory = Path.Combine(Application.persistentDataPath, "BH_Recording");
@@ -91,7 +132,7 @@ void Start()
}
// Initialize the video encoder with the output resolution
- _videoEncoder = new VideoEncoder(_outputWidth, _outputHeight, FrameRate, RecordingDuration, outputDirectory);
+ _videoEncoder = new VideoEncoder(_outputWidth, _outputHeight, FrameRate, RecordingDuration, outputDirectory, MirrorVertically);
_captureInterval = 1.0f / FrameRate;
_nextCaptureTime = Time.time;
@@ -99,6 +140,20 @@ void Start()
void OnDestroy()
{
+ // Optimized: Proper cleanup of RenderTextures
+ if (_captureRT != null)
+ {
+ _captureRT.Release();
+ _captureRT = null;
+ }
+ if (_fullScreenRT != null)
+ {
+ _fullScreenRT.Release();
+ _fullScreenRT = null;
+ }
+ _rgbConversionBuffer = null;
+ _frameDataBuffer = null;
+ _rgbaDataBuffer = null;
if (_videoEncoder != null) // can be null since Start() may not have been called
{
_videoEncoder.Dispose();
@@ -165,20 +220,10 @@ public string StopRecordingAndSaveLastMinute()
private IEnumerator CaptureFrames()
{
#if BETAHUB_DEBUG
-
UnityEngine.Debug.Log($"Game resolution: {_gameWidth}x{_gameHeight}");
UnityEngine.Debug.Log($"Output resolution: {_outputWidth}x{_outputHeight}");
-
#endif
- RenderTexture scaledRT = null;
- Texture2D scaledTexture = null;
- if (_outputWidth != _gameWidth || _outputHeight != _gameHeight)
- {
- scaledRT = new RenderTexture(_outputWidth, _outputHeight, 0, RenderTextureFormat.ARGB32);
- scaledTexture = new Texture2D(_outputWidth, _outputHeight, TextureFormat.RGB24, false);
- }
-
while (IsRecording)
{
yield return new WaitForEndOfFrame();
@@ -187,43 +232,159 @@ private IEnumerator CaptureFrames()
{
_nextCaptureTime += _captureInterval;
- // 1. Capture the full-res frame
- _screenShot.ReadPixels(new Rect(0, 0, _gameWidth, _gameHeight), 0, 0);
- _screenShot.Apply();
-
- byte[] frameData;
-
- if (scaledRT != null)
+ // Check if we should use a custom render texture or capture from screen
+ if (CaptureRenderTexture != null)
{
- // 2a. Blit (scale) to RT
- Graphics.Blit(_screenShot, scaledRT);
-
- // 2b. Read pixels from scaled RT
- RenderTexture.active = scaledRT;
- scaledTexture.ReadPixels(new Rect(0, 0, _outputWidth, _outputHeight), 0, 0);
- scaledTexture.Apply();
- RenderTexture.active = null;
-
- // 3a. Draw overlays on the scaled texture
- var painter = new TexturePainter(scaledTexture);
- painter.DrawNumber(5, 5, (int)_fps, Color.white, 2);
-
- frameData = scaledTexture.GetRawTextureData();
+ // Use the specified render texture instead of screen capture
+ if (CaptureRenderTexture.width == _outputWidth && CaptureRenderTexture.height == _outputHeight)
+ {
+ // Direct readback if dimensions match output resolution
+ AsyncGPUReadback.Request(CaptureRenderTexture, 0, OnCompleteReadback);
+ }
+ else
+ {
+ // Scale to output resolution if dimensions don't match
+ Graphics.Blit(CaptureRenderTexture, _captureRT);
+ AsyncGPUReadback.Request(_captureRT, 0, OnCompleteReadback);
+ }
}
else
{
- // 2b. Draw overlays on the original screenshot
- _texturePainter.DrawNumber(5, 5, (int)_fps, Color.white, 2);
- frameData = _screenShot.GetRawTextureData();
+ // Original screen capture logic
+ if (_fullScreenRT != null)
+ {
+ // First capture full screen
+ Graphics.Blit(null, _fullScreenRT);
+ // Then scale down to output resolution
+ Graphics.Blit(_fullScreenRT, _captureRT);
+ }
+ else
+ {
+ // Direct capture when no scaling needed
+ Graphics.Blit(null, _captureRT);
+ }
+
+ // Use AsyncGPUReadback for non-blocking frame capture
+ AsyncGPUReadback.Request(_captureRT, 0, OnCompleteReadback);
}
+ }
+ }
+ }
+
+ // Optimized: Async callback for GPU readback completion
+ private void OnCompleteReadback(AsyncGPUReadbackRequest request)
+ {
+ if (request.hasError)
+ {
+ #if BETAHUB_DEBUG
+ UnityEngine.Debug.LogError("AsyncGPUReadback request failed");
+ #endif
+ return;
+ }
+
+ if (!IsRecording) return; // Recording might have stopped while waiting for readback
+
+ // Get the raw data from GPU using safe copy to avoid allocation
+ var rawData = request.GetData();
+ SafeCopyNativeArrayToByteArray(rawData, ref _frameDataBuffer);
+
+ // Apply FPS overlay and get the processed RGB data
+ var processedFrameData = ApplyFPSOverlay(_frameDataBuffer, _outputWidth, _outputHeight);
+
+ // Send frame to encoder
+ _videoEncoder.AddFrame(processedFrameData);
+ }
+
+ // Helper method to apply FPS overlay to raw frame data
+ private byte[] ApplyFPSOverlay(byte[] frameData, int width, int height)
+ {
+ if (_rgbConversionBuffer == null)
+ {
+ // no buffer could mean that we're in the middle of shutting down
+ return frameData;
+ }
+
+ // Copy frameData to _rgbaDataBuffer to avoid modifying the original
+ if (_rgbaDataBuffer == null || _rgbaDataBuffer.Length != frameData.Length)
+ {
+ _rgbaDataBuffer = new byte[frameData.Length];
+ }
+ System.Array.Copy(frameData, _rgbaDataBuffer, frameData.Length);
+
+ // Create a byte buffer wrapper to work with the frame data directly
+ var bufferWrapper = new ByteBufferWrapper(_rgbaDataBuffer, width, height, 4); // RGBA format
+
+ // Draw FPS overlay directly on the buffer
+ // flipY=true means Y=0 is at the bottom, so coordinates work like mathematical coordinates
+ var painter = new TexturePainter(bufferWrapper, flipY: true);
+ painter.DrawNumber(5, 5, (int)_fps, Color.white, 2); // This will draw near bottom-left corner
- _videoEncoder.AddFrame(frameData);
+ // Draw cursor if enabled
+ if (RenderCursor)
+ {
+ // Get mouse position in screen coordinates
+ Vector3 mousePos = Input.mousePosition;
+
+ // Convert screen coordinates to texture coordinates
+ // Screen coordinates: (0,0) at bottom-left, (Screen.width, Screen.height) at top-right
+ // Texture coordinates depend on scaling and capture source
+ int cursorX, cursorY;
+
+ if (CaptureRenderTexture != null)
+ {
+ // When using a custom render texture, we need to map mouse position to texture space
+ // This assumes the render texture represents the full screen view
+ cursorX = Mathf.RoundToInt((mousePos.x / Screen.width) * width);
+ cursorY = Mathf.RoundToInt((mousePos.y / Screen.height) * height);
+ }
+ else
+ {
+ // Direct screen capture - account for potential downscaling
+ cursorX = Mathf.RoundToInt((mousePos.x / _gameWidth) * width);
+ cursorY = Mathf.RoundToInt((mousePos.y / _gameHeight) * height);
}
+
+ // Draw cursor with white fill and black outline for visibility
+ painter.DrawCursor(cursorX, cursorY, Color.white, Color.black, 1);
+ }
+
+ // Convert RGBA32 to RGB24 by removing alpha channel
+ for (int i = 0, j = 0; i < _rgbaDataBuffer.Length; i += 4, j += 3)
+ {
+ _rgbConversionBuffer[j] = _rgbaDataBuffer[i]; // R
+ _rgbConversionBuffer[j + 1] = _rgbaDataBuffer[i + 1]; // G
+ _rgbConversionBuffer[j + 2] = _rgbaDataBuffer[i + 2]; // B
+ // Skip alpha channel (_rgbaDataBuffer[i + 3])
+ }
+
+ return _rgbConversionBuffer;
+ }
+
+ ///
+ /// Safely copies data from a NativeArray to a byte array, resizing the target array if necessary.
+ /// This avoids GC allocations by reusing existing arrays when possible.
+ ///
+ /// The source NativeArray to copy from
+ /// The target byte array to copy to (will be resized if needed)
+ private void SafeCopyNativeArrayToByteArray(NativeArray source, ref byte[] target)
+ {
+ int requiredSize = source.Length;
+
+ // Resize target array if it's null or too small
+ if (target == null || target.Length != requiredSize)
+ {
+ int oldSize = target?.Length ?? 0;
+
+ target = new byte[requiredSize];
+ #if BETAHUB_DEBUG
+ UnityEngine.Debug.Log($"Resized buffer from {oldSize} to {requiredSize} bytes");
+ #endif
}
+
+ // Use CopyTo for efficient copying without allocation
+
+ source.CopyTo(target);
- // Clean up if needed
- if (scaledTexture != null) Destroy(scaledTexture);
- if (scaledRT != null) scaledRT.Release();
}
}
}
\ No newline at end of file
diff --git a/Runtime/Scripts/TexturePainter.cs b/Runtime/Scripts/TexturePainter.cs
index 1633dc1..ac25194 100644
--- a/Runtime/Scripts/TexturePainter.cs
+++ b/Runtime/Scripts/TexturePainter.cs
@@ -2,9 +2,71 @@
namespace BetaHub
{
+ ///
+ /// Wrapper for byte buffer that provides pixel access without needing to know color component offsets
+ ///
+ public class ByteBufferWrapper
+ {
+ private byte[] buffer;
+ private int width;
+ private int height;
+ private int bytesPerPixel;
+
+ public ByteBufferWrapper(byte[] buffer, int width, int height, int bytesPerPixel = 4)
+ {
+ this.buffer = buffer;
+ this.width = width;
+ this.height = height;
+ this.bytesPerPixel = bytesPerPixel;
+ }
+
+ public void SetPixel(int x, int y, Color color)
+ {
+ if (x < 0 || x >= width || y < 0 || y >= height) return;
+
+ int index = (y * width + x) * bytesPerPixel;
+ if (index + bytesPerPixel - 1 >= buffer.Length) return;
+
+ // RGBA format
+ buffer[index] = (byte)(color.r * 255); // R
+ buffer[index + 1] = (byte)(color.g * 255); // G
+ buffer[index + 2] = (byte)(color.b * 255); // B
+ if (bytesPerPixel == 4)
+ {
+ buffer[index + 3] = (byte)(color.a * 255); // A
+ }
+ }
+
+ public Color GetPixel(int x, int y)
+ {
+ if (x < 0 || x >= width || y < 0 || y >= height) return Color.clear;
+
+ int index = (y * width + x) * bytesPerPixel;
+ if (index + bytesPerPixel - 1 >= buffer.Length) return Color.clear;
+
+ float r = buffer[index] / 255f;
+ float g = buffer[index + 1] / 255f;
+ float b = buffer[index + 2] / 255f;
+ float a = bytesPerPixel == 4 ? buffer[index + 3] / 255f : 1f;
+
+ return new Color(r, g, b, a);
+ }
+
+ public int Width => width;
+ public int Height => height;
+ }
+
+ ///
+ /// TexturePainter provides drawing operations on byte buffers.
+ ///
+ /// Y Coordinate System:
+ /// - When flipY is false: Y=0 is at the top of the image, Y increases downward (standard screen coordinates)
+ /// - When flipY is true: Y=0 is at the bottom of the image, Y increases upward (mathematical/OpenGL coordinates)
+ ///
public class TexturePainter
{
- private Texture2D texture;
+ private ByteBufferWrapper bufferWrapper;
+ private bool flipY;
private static readonly byte[][] digits = new byte[][]
{
new byte[] // 0
@@ -89,9 +151,27 @@ public class TexturePainter
}
};
- public TexturePainter(Texture2D texture)
+ public TexturePainter(ByteBufferWrapper bufferWrapper, bool flipY = false)
+ {
+ this.bufferWrapper = bufferWrapper;
+ this.flipY = flipY;
+ }
+
+ ///
+ /// Transforms Y coordinate based on flipY setting.
+ /// If flipY is true, flips Y coordinate so that Y=0 is at the bottom.
+ ///
+ private int TransformY(int y)
+ {
+ return flipY ? (bufferWrapper.Height - 1 - y) : y;
+ }
+
+ ///
+ /// Sets a pixel with coordinate transformation applied.
+ ///
+ private void SetPixel(int x, int y, Color color)
{
- this.texture = texture;
+ bufferWrapper.SetPixel(x, TransformY(y), color);
}
public void DrawVerticalProgressBar(int x, int y, int width, int height, float progress, Color barColor, Color borderColor)
@@ -107,12 +187,9 @@ public void DrawVerticalProgressBar(int x, int y, int width, int height, float p
{
for (int j = 1; j < progressHeight - 1; j++)
{
- texture.SetPixel(x + i, y + j, barColor);
+ SetPixel(x + i, y + j, barColor);
}
}
-
- // Apply the changes to the texture
- texture.Apply();
}
private void DrawRectangle(int x, int y, int width, int height, Color color)
@@ -120,15 +197,15 @@ private void DrawRectangle(int x, int y, int width, int height, Color color)
// Draw top and bottom borders
for (int i = 0; i < width; i++)
{
- texture.SetPixel(x + i, y, color);
- texture.SetPixel(x + i, y + height - 1, color);
+ SetPixel(x + i, y, color);
+ SetPixel(x + i, y + height - 1, color);
}
// Draw left and right borders
for (int i = 0; i < height; i++)
{
- texture.SetPixel(x, y + i, color);
- texture.SetPixel(x + width - 1, y + i, color);
+ SetPixel(x, y + i, color);
+ SetPixel(x + width - 1, y + i, color);
}
}
@@ -154,16 +231,75 @@ public void DrawNumber(int x, int y, int number, Color color, int scale = 1)
{
for (int sx = 0; sx < scale; sx++)
{
- texture.SetPixel(x + (dx * scale) + sx + i * (digitWidth * scale + spacing), y + ((digitHeight - 1 - dy) * scale) + sy, color);
+ SetPixel(x + (dx * scale) + sx + i * (digitWidth * scale + spacing), y + ((digitHeight - 1 - dy) * scale) + sy, color);
}
}
}
}
}
}
+ }
+
+ ///
+ /// Draws a simple mouse cursor at the specified coordinates.
+ /// The cursor is drawn as an arrow shape with an outline for visibility.
+ ///
+ /// X coordinate of the cursor tip
+ /// Y coordinate of the cursor tip
+ /// Main color of the cursor
+ /// Outline color for better visibility
+ /// Scale factor for the cursor size
+ public void DrawCursor(int x, int y, Color color, Color outlineColor, int scale = 1)
+ {
+ // Define cursor shape as a simple arrow
+ // This represents a 11x16 cursor bitmap
+ byte[] cursorBitmap = new byte[]
+ {
+ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // row 0
+ 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, // row 1
+ 1, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, // row 2
+ 1, 2, 2, 1, 0, 0, 0, 0, 0, 0, 0, // row 3
+ 1, 2, 2, 2, 1, 0, 0, 0, 0, 0, 0, // row 4
+ 1, 2, 2, 2, 2, 1, 0, 0, 0, 0, 0, // row 5
+ 1, 2, 2, 2, 2, 2, 1, 0, 0, 0, 0, // row 6
+ 1, 2, 2, 2, 2, 2, 2, 1, 0, 0, 0, // row 7
+ 1, 2, 2, 2, 2, 2, 2, 2, 1, 0, 0, // row 8
+ 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 0, // row 9
+ 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, // row 10
+ 1, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, // row 11
+ 1, 2, 2, 1, 2, 2, 1, 0, 0, 0, 0, // row 12
+ 1, 2, 1, 0, 1, 2, 2, 1, 0, 0, 0, // row 13
+ 1, 1, 0, 0, 1, 2, 2, 1, 0, 0, 0, // row 14
+ 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0 // row 15
+ };
+
+ int cursorWidth = 11;
+ int cursorHeight = 16;
- // Apply the changes to the texture
- texture.Apply();
+ // Draw the cursor with scaling
+ for (int dy = 0; dy < cursorHeight; dy++)
+ {
+ for (int dx = 0; dx < cursorWidth; dx++)
+ {
+ // Flip the cursor vertically if flipY is true to account for coordinate system
+ int bitmapRow = flipY ? (cursorHeight - 1 - dy) : dy;
+ byte pixelValue = cursorBitmap[bitmapRow * cursorWidth + dx];
+
+ if (pixelValue > 0)
+ {
+ Color pixelColor = pixelValue == 1 ? outlineColor : color;
+
+ // Apply scaling
+ for (int sy = 0; sy < scale; sy++)
+ {
+ for (int sx = 0; sx < scale; sx++)
+ {
+ SetPixel(x + (dx * scale) + sx, y + (dy * scale) + sy, pixelColor);
+ }
+ }
+ }
+ }
+ }
}
}
}
\ No newline at end of file
diff --git a/Runtime/Scripts/VideoEncoder.cs b/Runtime/Scripts/VideoEncoder.cs
index dcaf262..27a841f 100644
--- a/Runtime/Scripts/VideoEncoder.cs
+++ b/Runtime/Scripts/VideoEncoder.cs
@@ -4,9 +4,22 @@
using System.Collections.Generic;
using UnityEngine;
using System.Threading.Tasks;
+using System; // Added for Guid
namespace BetaHub
{
+ ///
+ /// VideoEncoder handles video recording and encoding using FFmpeg.
+ ///
+ /// Bug Fix (File Access Conflicts):
+ /// - Uses unique instance directories to prevent conflicts between multiple VideoEncoder instances
+ /// - Implements retry logic with exponential backoff for file deletion operations
+ /// - Gracefully handles IOException when files are still in use by FFmpeg processes
+ /// - Properly cleans up instance directories when empty
+ ///
+ /// This fixes the issue where scene reloads would cause IOException spam due to
+ /// multiple VideoEncoder instances trying to access the same segment files.
+ ///
public class VideoEncoder
{
private IProcessWrapper ffmpegProcess;
@@ -25,6 +38,12 @@ public class VideoEncoder
private float frameInterval;
private bool debugMode;
+
+ // Unique instance identifier to prevent conflicts between multiple instances
+ private readonly string instanceId;
+
+ // Control vertical mirroring of the video
+ private readonly bool mirrorVertically;
// if set to true, the encoding thread will pause adding new frames
public bool IsPaused { get; set; }
@@ -36,16 +55,20 @@ public class VideoEncoder
private volatile bool _stopRequest = false;
private volatile bool _stopRequestHandled = false;
- public VideoEncoder(int width, int height, int frameRate, int recordingDurationSeconds, string outputDir = "Recording")
+ public VideoEncoder(int width, int height, int frameRate, int recordingDurationSeconds, string baseOutputDir = "Recording", bool mirrorVertically = false)
{
this.width = width;
this.height = height;
this.frameRate = frameRate;
- this.outputDir = outputDir;
+ this.mirrorVertically = mirrorVertically;
+
+ // Create unique instance identifier and output directory
+ this.instanceId = Guid.NewGuid().ToString("N").Substring(0, 8); // Use first 8 characters of GUID
+ this.outputDir = Path.Combine(baseOutputDir, instanceId);
this.outputPathPattern = Path.Combine(outputDir, "segment_%03d.mp4");
#if BETAHUB_DEBUG
- UnityEngine.Debug.Log("Video output directory: " + outputDir);
+ UnityEngine.Debug.Log($"Video output directory: {outputDir} (instance: {instanceId})");
#endif
Directory.CreateDirectory(outputDir);
@@ -66,9 +89,16 @@ public void Dispose()
if (ffmpegProcess != null && ffmpegProcess.IsRunning())
{
SendStopRequestAndWait();
+
+ // Give the process a moment to fully release file handles
+ System.Threading.Thread.Sleep(100);
}
- RemoveAllSegments();
+ // Clean up segments with retry logic for better file access handling
+ RemoveAllSegmentsWithRetry();
+
+ // Clean up the unique instance directory if it's empty
+ CleanupInstanceDirectory();
}
public void StartEncoding()
@@ -100,7 +130,6 @@ public void StartEncoding()
"-s", $"{width}x{height}",
"-r", frameRate.ToString(),
"-i", "-",
- "-vf", "vflip",
"-c:v", encoder,
"-pix_fmt", "yuv420p"
};
@@ -110,6 +139,13 @@ public void StartEncoding()
arguments.Add("-preset");
arguments.Add(presetName);
}
+
+ // Add vertical mirroring filter if enabled
+ if (mirrorVertically)
+ {
+ arguments.Add("-vf");
+ arguments.Add("vflip");
+ }
arguments.AddRange(new[]
{
@@ -318,6 +354,9 @@ private void RemoveAllSegments()
// cleanups only the old segments, keeping the ones with the latest segment numbers
private void CleanupSegments()
{
+ if (!Directory.Exists(outputDir))
+ return;
+
var directoryInfo = new DirectoryInfo(outputDir);
var filesToDelete = directoryInfo.GetFiles("segment_*.mp4")
@@ -327,7 +366,40 @@ private void CleanupSegments()
foreach (var file in filesToDelete)
{
- file.Delete();
+ // Retry logic for file deletion to handle file access conflicts
+ int retryCount = 0;
+ const int maxRetries = 3;
+ const int retryDelayMs = 25;
+
+ while (retryCount < maxRetries)
+ {
+ try
+ {
+ file.Delete();
+ break; // Success, exit retry loop
+ }
+ catch (IOException ex) when (ex.Message.Contains("being used by another process"))
+ {
+ retryCount++;
+ if (retryCount >= maxRetries)
+ {
+ #if BETAHUB_DEBUG
+ UnityEngine.Debug.LogWarning($"Could not delete old segment file {file.Name} after {maxRetries} attempts. File may still be in use.");
+ #endif
+ }
+ else
+ {
+ System.Threading.Thread.Sleep(retryDelayMs * retryCount);
+ }
+ }
+ catch (System.Exception ex)
+ {
+ #if BETAHUB_DEBUG
+ UnityEngine.Debug.LogError($"Unexpected error deleting old segment file {file.Name}: {ex.Message}");
+ #endif
+ break; // Don't retry for unexpected errors
+ }
+ }
}
}
@@ -526,6 +598,85 @@ private static string GetFfmpegPath()
return path;
}
+
+ private void RemoveAllSegmentsWithRetry()
+ {
+ if (!Directory.Exists(outputDir))
+ return;
+
+ var directoryInfo = new DirectoryInfo(outputDir);
+ var files = directoryInfo.GetFiles("segment_*.mp4");
+
+ foreach (var file in files)
+ {
+ // Retry logic for file deletion to handle file access conflicts
+ int retryCount = 0;
+ const int maxRetries = 5;
+ const int retryDelayMs = 50;
+
+ while (retryCount < maxRetries)
+ {
+ try
+ {
+ file.Delete();
+ break; // Success, exit retry loop
+ }
+ catch (IOException ex) when (ex.Message.Contains("being used by another process"))
+ {
+ retryCount++;
+ if (retryCount >= maxRetries)
+ {
+ UnityEngine.Debug.LogWarning($"Could not delete segment file {file.Name} after {maxRetries} attempts. File may still be in use. Error: {ex.Message}");
+ }
+ else
+ {
+ #if BETAHUB_DEBUG
+ UnityEngine.Debug.Log($"Retrying deletion of {file.Name} (attempt {retryCount}/{maxRetries})");
+ #endif
+ System.Threading.Thread.Sleep(retryDelayMs * retryCount); // Exponential backoff
+ }
+ }
+ catch (System.Exception ex)
+ {
+ UnityEngine.Debug.LogError($"Unexpected error deleting segment file {file.Name}: {ex.Message}");
+ break; // Don't retry for unexpected errors
+ }
+ }
+ }
+ }
+
+ private void CleanupInstanceDirectory()
+ {
+ try
+ {
+ if (Directory.Exists(outputDir))
+ {
+ // Check if directory is empty (no files left)
+ var remainingFiles = Directory.GetFiles(outputDir);
+ var remainingDirs = Directory.GetDirectories(outputDir);
+
+ if (remainingFiles.Length == 0 && remainingDirs.Length == 0)
+ {
+ Directory.Delete(outputDir);
+ #if BETAHUB_DEBUG
+ UnityEngine.Debug.Log($"Cleaned up empty instance directory: {outputDir}");
+ #endif
+ }
+ else
+ {
+ #if BETAHUB_DEBUG
+ UnityEngine.Debug.Log($"Instance directory not empty, keeping: {outputDir} (files: {remainingFiles.Length}, dirs: {remainingDirs.Length})");
+ #endif
+ }
+ }
+ }
+ catch (System.Exception ex)
+ {
+ #if BETAHUB_DEBUG
+ UnityEngine.Debug.LogWarning($"Could not clean up instance directory {outputDir}: {ex.Message}");
+ #endif
+ }
+ }
}
public class CircularBuffer