arcane-os 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +8 -8
  3. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
  4. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
  5. package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
  6. package/browser-runtime/ai/browser-speech-artifacts.mjs +3230 -397
  7. package/browser-runtime/ai/browser-speech-providers.mjs +1141 -157
  8. package/browser-runtime/ai/browser-speech.mjs +2 -0
  9. package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
  10. package/browser-runtime/ai/model-controller.mjs +285 -95
  11. package/browser-runtime/ai/speech-worker-client.mjs +247 -32
  12. package/browser-runtime/ai/speech-worker-runtime.mjs +2310 -167
  13. package/browser-runtime/event-manager.mjs +1097 -1
  14. package/docs/architecture.md +2 -2
  15. package/docs/event-manager.md +155 -27
  16. package/docs/reference/README.md +27 -27
  17. package/docs/reference/ai/browser-speech-package-authority.json +835 -0
  18. package/docs/reference/ai/browser-speech.md +1162 -246
  19. package/docs/reference/ai/browser-wasm.md +18 -7
  20. package/docs/reference/availability-and-normalization.md +6 -3
  21. package/docs/reference/behavioral-testing.md +29 -6
  22. package/docs/reference/cli.md +117 -9
  23. package/docs/reference/core/arcane-ai-contracts.md +1 -1
  24. package/docs/reference/event-manager.md +577 -32
  25. package/docs/reference/inventory/package-api.json +478 -2
  26. package/docs/reference/inventory/runtime-components.json +108 -44
  27. package/docs/reference/inventory/runtime-modules.json +131 -53
  28. package/docs/reference/mail.md +316 -0
  29. package/docs/reference/protocols.md +157 -43
  30. package/docs/reference/runtime-components.md +258 -83
  31. package/docs/reference/runtime-modules.md +613 -77
  32. package/docs/reference/sdk-api.md +1014 -25
  33. package/package.json +5 -4
  34. package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
  35. package/runtime/arcane/components/app-bar.html +34 -13
  36. package/runtime/arcane/components/assistant-panel.html +110 -57
  37. package/runtime/arcane/components/calculator.html +7 -4
  38. package/runtime/arcane/components/chart.html +58 -17
  39. package/runtime/arcane/components/chat.html +606 -136
  40. package/runtime/arcane/components/conversation-view.html +13 -6
  41. package/runtime/arcane/components/dashboard-config.html +96 -59
  42. package/runtime/arcane/components/data-maintenance.html +69 -14
  43. package/runtime/arcane/components/data-view.html +53 -7
  44. package/runtime/arcane/components/directory-picker.html +118 -32
  45. package/runtime/arcane/components/document-inspector.html +47 -10
  46. package/runtime/arcane/components/file-drop.html +81 -35
  47. package/runtime/arcane/components/file-inspector.html +72 -22
  48. package/runtime/arcane/components/file-manager.html +374 -79
  49. package/runtime/arcane/components/integration-settings.html +12 -5
  50. package/runtime/arcane/components/local-ai-status.html +48 -19
  51. package/runtime/arcane/components/markdown-document.html +161 -68
  52. package/runtime/arcane/components/markdown-editor.html +110 -33
  53. package/runtime/arcane/components/media-embed.html +8 -5
  54. package/runtime/arcane/components/modal.html +15 -5
  55. package/runtime/arcane/components/output-panel.html +28 -23
  56. package/runtime/arcane/components/preferences-form.html +22 -4
  57. package/runtime/arcane/components/record-timeline.html +18 -2
  58. package/runtime/arcane/components/relationship-board.html +23 -3
  59. package/runtime/arcane/components/screen-capture.html +10 -4
  60. package/runtime/arcane/components/source-code-viewer.html +76 -9
  61. package/runtime/arcane/components/source-explanation.html +23 -3
  62. package/runtime/arcane/components/speech.html +462 -384
  63. package/runtime/arcane/components/summary-strip.html +22 -11
  64. package/runtime/arcane/components/table.html +39 -21
  65. package/runtime/arcane/components/task-progress.html +79 -21
  66. package/runtime/arcane/components/terminal-workspace.html +7 -4
  67. package/runtime/arcane/components/theme-editor.html +7 -3
  68. package/runtime/arcane/components/unified-inbox.html +9 -4
  69. package/runtime/arcane/components/voice-transcription.html +639 -98
  70. package/runtime/arcane/components/weather-widget.html +5 -3
  71. package/runtime/arcane/components/web-navigator.html +48 -8
  72. package/runtime/arcane/entities/Chat.js +1 -1
  73. package/runtime/arcane/entities/User.js +110 -23
  74. package/runtime/arcane/modules/AI.js +2109 -130
  75. package/runtime/arcane/modules/AIProviderRuntime.js +720 -13
  76. package/runtime/arcane/modules/AIRuntimeState.js +109 -52
  77. package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
  78. package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
  79. package/runtime/arcane/modules/CalculatorEngine.js +63 -3
  80. package/runtime/arcane/modules/CommunicationAppController.js +588 -28
  81. package/runtime/arcane/modules/CommunicationHub.js +590 -10
  82. package/runtime/arcane/modules/ComponentContracts.js +470 -0
  83. package/runtime/arcane/modules/ConversationTimebox.js +152 -33
  84. package/runtime/arcane/modules/DBLS.js +40 -7
  85. package/runtime/arcane/modules/DBOPFS.js +35 -11
  86. package/runtime/arcane/modules/DataMaintenance.js +12 -2
  87. package/runtime/arcane/modules/Errors.js +65 -7
  88. package/runtime/arcane/modules/HTMLImport.js +198 -14
  89. package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
  90. package/runtime/arcane/modules/Mail.js +738 -115
  91. package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
  92. package/runtime/arcane/modules/MailTransport.mjs +197 -39
  93. package/runtime/arcane/modules/Ollama.js +36 -1
  94. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
  95. package/runtime/arcane/modules/PreferenceStore.js +367 -33
  96. package/runtime/arcane/modules/RecordReviewStore.js +322 -23
  97. package/runtime/arcane/modules/ScreenCapture.js +1397 -15
  98. package/runtime/arcane/modules/SpeechPlayback.js +438 -41
  99. package/runtime/arcane/modules/TerminalClient.js +277 -12
  100. package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
  101. package/runtime/arcane/modules/ThemeManager.js +39 -7
  102. package/runtime/arcane/modules/TimeGuard.js +131 -20
  103. package/runtime/arcane/modules/WaitForComponent.js +386 -33
  104. package/schemas/arcane-lock.schema.json +2 -2
  105. package/src/cli/main.mjs +435 -9
  106. package/src/event-manager.mjs +1097 -1
  107. package/src/import-map.mjs +21 -3
  108. package/src/index.mjs +13 -0
  109. package/src/installed-sdk-runtime.mjs +112 -0
  110. package/src/mail-api.mjs +22 -0
  111. package/src/mail-credentials.mjs +667 -0
  112. package/src/mail-server.mjs +1769 -0
  113. package/src/mail.mjs +261 -0
  114. package/src/sdk-browser-runtime.mjs +85 -41
  115. package/src/testing-loader.mjs +7 -0
  116. package/src/toolchain.mjs +3 -0
  117. package/src/workspace.mjs +1 -1
@@ -1,20 +1,1402 @@
1
+ import {createArcaneEventSource} from 'arcane-os/event-manager';
1
2
  import GifEncoder from './GifEncoder.js';
2
3
 
3
- function captureEvent(type,detail){return new CustomEvent(`capture-${type}`,{detail});}
4
- function canvasBlob(canvas,type,quality){return new Promise((resolve,reject)=>canvas.toBlob(blob=>blob?resolve(blob):reject(new Error('Unable to encode the captured image.')),type,quality));}
5
- function supportedRecorderType(Recorder){for(const type of ['video/webm;codecs=vp9,opus','video/webm;codecs=vp8,opus','video/webm','video/mp4'])if(Recorder.isTypeSupported?.(type))return type;return '';}
4
+ export const SCREEN_CAPTURE_EVENT_TYPES=Object.freeze({
5
+ displaySelectionRequested:'capture-requesting',
6
+ captureStarted:'capture-start',
7
+ captureCompleted:'capture-result',
8
+ captureFailed:'capture-error',
9
+ captureStopped:'capture-stop'
10
+ });
11
+
12
+ export const SCREEN_CAPTURE_STATUSES=Object.freeze({
13
+ selectingDisplay:'selecting-display',
14
+ recording:'recording',
15
+ captureReady:'capture-ready',
16
+ captureRejected:'capture-rejected',
17
+ captureStopped:'capture-stopped'
18
+ });
19
+
20
+ export const SCREEN_CAPTURE_IMAGE_TYPE_FALLBACK=Object.freeze({
21
+ when:'blob-type-unreported',
22
+ mimeType:'image/png',
23
+ extension:'png'
24
+ });
25
+
26
+ export const SCREEN_CAPTURE_ERRORS=Object.freeze({
27
+ active:Object.freeze({
28
+ code:'ARCANE_SCREEN_CAPTURE_OPERATION_ACTIVE',
29
+ reason:'screen-capture-operation-active'
30
+ }),
31
+ disposed:Object.freeze({
32
+ code:'ARCANE_SCREEN_CAPTURE_DISPOSED',
33
+ reason:'screen-capture-disposed'
34
+ }),
35
+ optionsRecordRejected:Object.freeze({
36
+ code:'ARCANE_SCREEN_CAPTURE_OPTIONS_RECORD_REJECTED',
37
+ reason:'screen-capture-options-record-rejected'
38
+ }),
39
+ abortSignalRejected:Object.freeze({
40
+ code:'ARCANE_SCREEN_CAPTURE_ABORT_SIGNAL_REJECTED',
41
+ reason:'screen-capture-abort-signal-rejected'
42
+ }),
43
+ operationIdRejected:Object.freeze({
44
+ code:'ARCANE_SCREEN_CAPTURE_OPERATION_ID_REJECTED',
45
+ reason:'screen-capture-operation-id-rejected'
46
+ }),
47
+ cleanupRejected:Object.freeze({
48
+ code:'ARCANE_SCREEN_CAPTURE_CLEANUP_REJECTED',
49
+ reason:'screen-capture-cleanup-rejected'
50
+ }),
51
+ displayUnavailable:Object.freeze({
52
+ code:'ARCANE_SCREEN_CAPTURE_DISPLAY_UNAVAILABLE',
53
+ reason:'screen-capture-display-unavailable'
54
+ }),
55
+ displayMetadataRejected:Object.freeze({
56
+ code:'ARCANE_SCREEN_CAPTURE_DISPLAY_METADATA_REJECTED',
57
+ reason:'screen-capture-display-metadata-rejected'
58
+ }),
59
+ displayPlaybackRejected:Object.freeze({
60
+ code:'ARCANE_SCREEN_CAPTURE_DISPLAY_PLAYBACK_REJECTED',
61
+ reason:'screen-capture-display-playback-rejected'
62
+ }),
63
+ displaySelectionCancelled:Object.freeze({
64
+ code:'ARCANE_SCREEN_CAPTURE_DISPLAY_SELECTION_CANCELLED',
65
+ reason:'screen-capture-display-selection-cancelled'
66
+ }),
67
+ displaySelectionRejected:Object.freeze({
68
+ code:'ARCANE_SCREEN_CAPTURE_DISPLAY_SELECTION_REJECTED',
69
+ reason:'screen-capture-display-selection-rejected'
70
+ }),
71
+ displayTrackEndedBeforeRecording:Object.freeze({
72
+ code:'ARCANE_SCREEN_CAPTURE_DISPLAY_TRACK_ENDED_BEFORE_RECORDING',
73
+ reason:'screen-capture-display-track-ended-before-recording'
74
+ }),
75
+ eventTypeUndeclared:Object.freeze({
76
+ code:'ARCANE_SCREEN_CAPTURE_EVENT_TYPE_UNDECLARED',
77
+ reason:'screen-capture-event-type-undeclared'
78
+ }),
79
+ gifEncodingRejected:Object.freeze({
80
+ code:'ARCANE_SCREEN_CAPTURE_GIF_ENCODING_REJECTED',
81
+ reason:'screen-capture-gif-encoding-rejected'
82
+ }),
83
+ gifFrameReadRejected:Object.freeze({
84
+ code:'ARCANE_SCREEN_CAPTURE_GIF_FRAME_READ_REJECTED',
85
+ reason:'screen-capture-gif-frame-read-rejected'
86
+ }),
87
+ imageCanvasUnavailable:Object.freeze({
88
+ code:'ARCANE_SCREEN_CAPTURE_IMAGE_CANVAS_UNAVAILABLE',
89
+ reason:'screen-capture-image-canvas-unavailable'
90
+ }),
91
+ imageDrawRejected:Object.freeze({
92
+ code:'ARCANE_SCREEN_CAPTURE_IMAGE_DRAW_REJECTED',
93
+ reason:'screen-capture-image-draw-rejected'
94
+ }),
95
+ imageEncodingRejected:Object.freeze({
96
+ code:'ARCANE_SCREEN_CAPTURE_IMAGE_ENCODING_REJECTED',
97
+ reason:'screen-capture-image-encoding-rejected'
98
+ }),
99
+ recorderConstructionRejected:Object.freeze({
100
+ code:'ARCANE_SCREEN_CAPTURE_RECORDER_CONSTRUCTION_REJECTED',
101
+ reason:'screen-capture-recorder-construction-rejected'
102
+ }),
103
+ recorderErrorReceived:Object.freeze({
104
+ code:'ARCANE_SCREEN_CAPTURE_RECORDER_ERROR_RECEIVED',
105
+ reason:'screen-capture-recorder-error-received'
106
+ }),
107
+ recorderStartRejected:Object.freeze({
108
+ code:'ARCANE_SCREEN_CAPTURE_RECORDER_START_REJECTED',
109
+ reason:'screen-capture-recorder-start-rejected'
110
+ }),
111
+ recorderStopRejected:Object.freeze({
112
+ code:'ARCANE_SCREEN_CAPTURE_RECORDER_STOP_REJECTED',
113
+ reason:'screen-capture-recorder-stop-rejected'
114
+ }),
115
+ recorderUnavailable:Object.freeze({
116
+ code:'ARCANE_SCREEN_CAPTURE_RECORDER_UNAVAILABLE',
117
+ reason:'screen-capture-recorder-unavailable'
118
+ }),
119
+ operationAborted:Object.freeze({
120
+ code:'ARCANE_SCREEN_CAPTURE_OPERATION_ABORTED',
121
+ reason:'screen-capture-operation-aborted'
122
+ }),
123
+ operationStopped:Object.freeze({
124
+ code:'ARCANE_SCREEN_CAPTURE_OPERATION_STOPPED',
125
+ reason:'screen-capture-operation-stopped'
126
+ }),
127
+ reset:Object.freeze({
128
+ code:'ARCANE_SCREEN_CAPTURE_OPERATION_RESET',
129
+ reason:'screen-capture-reset'
130
+ })
131
+ });
132
+
133
+ export const SCREEN_CAPTURE_ERROR_CODES=Object.freeze(Object.fromEntries(
134
+ Object.entries(SCREEN_CAPTURE_ERRORS).map(function mapScreenCaptureCode(entry){
135
+ return [entry[0],entry[1].code];
136
+ })
137
+ ));
138
+
139
+ export const SCREEN_CAPTURE_REASONS=Object.freeze({
140
+ displaySelectionRequested:'screen-capture-display-selection-requested',
141
+ gifCompleted:'screen-capture-gif-completed',
142
+ gifRecordingStarted:'screen-capture-gif-recording-started',
143
+ gifRecordingStopped:'screen-capture-gif-recording-stopped',
144
+ imageCompleted:'screen-capture-image-completed',
145
+ videoCompleted:'screen-capture-video-completed',
146
+ videoRecordingStarted:'screen-capture-video-recording-started',
147
+ videoRecordingStopped:'screen-capture-video-recording-stopped',
148
+ ...Object.fromEntries(Object.entries(SCREEN_CAPTURE_ERRORS).map(
149
+ function mapScreenCaptureReason(entry){return [entry[0],entry[1].reason];}
150
+ ))
151
+ });
152
+
153
+ const EVENT_NAMES=Object.freeze({
154
+ requesting:SCREEN_CAPTURE_EVENT_TYPES.displaySelectionRequested,
155
+ start:SCREEN_CAPTURE_EVENT_TYPES.captureStarted,
156
+ result:SCREEN_CAPTURE_EVENT_TYPES.captureCompleted,
157
+ error:SCREEN_CAPTURE_EVENT_TYPES.captureFailed,
158
+ stop:SCREEN_CAPTURE_EVENT_TYPES.captureStopped
159
+ });
160
+
161
+ function signalLike(value){
162
+ return value===undefined
163
+ ||value===null
164
+ ||(
165
+ typeof value==='object'
166
+ &&typeof value.aborted==='boolean'
167
+ &&typeof value.addEventListener==='function'
168
+ &&typeof value.removeEventListener==='function'
169
+ );
170
+ }
171
+
172
+ function captureError(contract,message,cause,ErrorType=Error){
173
+ const error=new ErrorType(message);
174
+ error.code=contract.code;
175
+ error.reason=contract.reason;
176
+ if(cause!==undefined)error.cause=cause;
177
+ return error;
178
+ }
179
+
180
+ function abortError(cause){
181
+ const error=captureError(
182
+ SCREEN_CAPTURE_ERRORS.operationAborted,
183
+ 'The screen capture operation was aborted.',
184
+ cause
185
+ );
186
+ error.name='AbortError';
187
+ return error;
188
+ }
189
+
190
+ function optionsRecord(value,label){
191
+ if(value===undefined)return {};
192
+ if(!value||typeof value!=='object'||Array.isArray(value)){
193
+ throw captureError(
194
+ SCREEN_CAPTURE_ERRORS.optionsRecordRejected,
195
+ label+' must be an object.',
196
+ undefined,
197
+ TypeError
198
+ );
199
+ }
200
+ if(!signalLike(value.signal)){
201
+ throw captureError(
202
+ SCREEN_CAPTURE_ERRORS.abortSignalRejected,
203
+ label+' signal must be an AbortSignal.',
204
+ undefined,
205
+ TypeError
206
+ );
207
+ }
208
+ return value;
209
+ }
210
+
211
+ function admittedOperationId(value){
212
+ if(value===undefined||value===null)return null;
213
+ if(typeof value!=='string'
214
+ ||value.trim()!==value
215
+ ||value.length<1
216
+ ||value.length>256){
217
+ throw captureError(
218
+ SCREEN_CAPTURE_ERRORS.operationIdRejected,
219
+ 'Screen capture operationId must contain 1-256 non-edge-whitespace characters.',
220
+ undefined,
221
+ TypeError
222
+ );
223
+ }
224
+ return value;
225
+ }
226
+
227
+ function normalizedWidth(value,fallback){
228
+ return Math.max(1,Number(value)||fallback);
229
+ }
230
+
231
+ function imageResultType(blob){
232
+ const reported=typeof blob?.type==='string'?blob.type.trim().toLowerCase():'';
233
+ if(!reported)return SCREEN_CAPTURE_IMAGE_TYPE_FALLBACK;
234
+ const subtype=reported.startsWith('image/')
235
+ ?reported.slice('image/'.length).split(';',1)[0]
236
+ :'';
237
+ const canonicalSubtype=subtype.split('+',1)[0];
238
+ const extension=canonicalSubtype==='jpeg'
239
+ ?'jpg'
240
+ :/^[a-z0-9][a-z0-9.-]*$/u.test(canonicalSubtype)
241
+ ?canonicalSubtype
242
+ :'bin';
243
+ return Object.freeze({mimeType:reported,extension});
244
+ }
245
+
246
+ function supportedRecorderType(Recorder){
247
+ for(const type of [
248
+ 'video/webm;codecs=vp9,opus',
249
+ 'video/webm;codecs=vp8,opus',
250
+ 'video/webm',
251
+ 'video/mp4'
252
+ ]){
253
+ if(Recorder.isTypeSupported?.(type))return type;
254
+ }
255
+ return '';
256
+ }
257
+
258
+ function reportDetachedError(error){
259
+ if(typeof globalThis.reportError==='function')globalThis.reportError(error);
260
+ else globalThis.console?.error?.(error);
261
+ }
6
262
 
7
263
  export default class ScreenCapture extends EventTarget{
8
- constructor({mediaDevices=globalThis.navigator?.mediaDevices,Recorder=globalThis.MediaRecorder,documentRef=globalThis.document}={}){super();this.mediaDevices=mediaDevices;this.Recorder=Recorder;this.document=documentRef;this.mode='idle';this.stream=null;this.recorder=null;this.startedAt=0;this.frames=[];this.timer=0;this.video=null;this.canvas=null;this.stopPromise=null;}
9
- available(){return Boolean(this.mediaDevices?.getDisplayMedia&&this.document);}
10
- async acquire({audio=false}={}){if(!this.available())throw new Error('Display capture is unavailable in this browser.');return this.mediaDevices.getDisplayMedia({video:{frameRate:{ideal:15,max:30}},audio:Boolean(audio)});}
11
- async captureImage({maxWidth=1920,type='image/png'}={}){this.assertIdle();this.emit('requesting',{mode:'image'});let stream;try{stream=await this.acquire();const {video,canvas}=await this.prepare(stream,maxWidth);canvas.getContext('2d').drawImage(video,0,0,canvas.width,canvas.height);const blob=await canvasBlob(canvas,type);const result={blob,mimeType:type,extension:type==='image/jpeg'?'jpg':'png',duration:0,width:canvas.width,height:canvas.height};this.emit('result',result);return result}catch(error){this.emit('error',{error,mode:'image'});throw error}finally{this.stopTracks(stream)}}
12
- async startVideo({audio=true}={}){this.assertIdle();if(!this.Recorder)throw new Error('Video recording is unavailable in this browser.');this.emit('requesting',{mode:'video'});this.stream=await this.acquire({audio});const mimeType=supportedRecorderType(this.Recorder);const chunks=[];this.recorder=new this.Recorder(this.stream,mimeType?{mimeType}:undefined);this.stopPromise=new Promise((resolve,reject)=>{this.recorder.addEventListener('dataavailable',event=>{if(event.data?.size)chunks.push(event.data)});this.recorder.addEventListener('stop',()=>{const type=this.recorder.mimeType||mimeType||'video/webm';resolve({blob:new Blob(chunks,{type}),mimeType:type,extension:type.includes('mp4')?'mp4':'webm',duration:Date.now()-this.startedAt})},{once:true});this.recorder.addEventListener('error',event=>reject(event.error||new Error('Video recording failed.')),{once:true})});this.mode='video';this.startedAt=Date.now();this.recorder.start(500);this.emit('start',{mode:'video'});return true;}
13
- async startGif({maxWidth=640,frameDelay=250}={}){this.assertIdle();this.emit('requesting',{mode:'gif'});this.stream=await this.acquire();const prepared=await this.prepare(this.stream,maxWidth);this.video=prepared.video;this.canvas=prepared.canvas;this.frames=[];this.mode='gif';this.startedAt=Date.now();const context=this.canvas.getContext('2d',{willReadFrequently:true});const sample=()=>{context.drawImage(this.video,0,0,this.canvas.width,this.canvas.height);this.frames.push(context.getImageData(0,0,this.canvas.width,this.canvas.height))};sample();this.timer=setInterval(sample,Math.max(100,frameDelay));this.gifDelay=Math.max(100,frameDelay);this.emit('start',{mode:'gif'});return true;}
14
- async stop(){if(this.mode==='idle')return null;const mode=this.mode;try{let result;if(mode==='video'){this.recorder.stop();result=await this.stopPromise}else{clearInterval(this.timer);const encoder=new GifEncoder(this.canvas.width,this.canvas.height);for(const frame of this.frames)encoder.addFrame(frame,{delay:this.gifDelay});result={blob:encoder.encode(),mimeType:'image/gif',extension:'gif',duration:Date.now()-this.startedAt,width:this.canvas.width,height:this.canvas.height}}this.emit('result',result);return result}catch(error){this.emit('error',{error,mode});throw error}finally{this.stopTracks(this.stream);this.reset()}}
15
- assertIdle(){if(this.mode!=='idle')throw new Error('Stop the active capture before starting another one.');}
16
- async prepare(stream,maxWidth){const video=this.document.createElement('video');video.muted=true;video.playsInline=true;video.srcObject=stream;await new Promise((resolve,reject)=>{video.addEventListener('loadedmetadata',resolve,{once:true});video.addEventListener('error',()=>reject(new Error('Unable to read the selected display.')),{once:true})});await video.play();const scale=Math.min(1,Math.max(1,Number(maxWidth)||1920)/video.videoWidth);const canvas=this.document.createElement('canvas');canvas.width=Math.max(1,Math.round(video.videoWidth*scale));canvas.height=Math.max(1,Math.round(video.videoHeight*scale));return {video,canvas};}
17
- stopTracks(stream){for(const track of stream?.getTracks?.()||[])track.stop()}
18
- reset(){clearInterval(this.timer);this.mode='idle';this.stream=null;this.recorder=null;this.frames=[];this.timer=0;this.video=null;this.canvas=null;this.stopPromise=null;this.startedAt=0;this.emit('stop',{});}
19
- emit(type,detail){this.dispatchEvent(captureEvent(type,detail));}
264
+ #acquisitions=new Set();
265
+ #disposed=false;
266
+ #disposing=false;
267
+ #events;
268
+ #operation=null;
269
+ #operationSequence=0;
270
+
271
+ constructor({
272
+ mediaDevices=globalThis.navigator?.mediaDevices,
273
+ Recorder=globalThis.MediaRecorder,
274
+ documentRef=globalThis.document
275
+ }={}){
276
+ super();
277
+ this.mediaDevices=mediaDevices;
278
+ this.Recorder=Recorder;
279
+ this.document=documentRef;
280
+ this.mode='idle';
281
+ this.stream=null;
282
+ this.recorder=null;
283
+ this.startedAt=0;
284
+ this.frames=[];
285
+ this.timer=0;
286
+ this.video=null;
287
+ this.canvas=null;
288
+ this.stopPromise=null;
289
+ this.gifDelay=0;
290
+ this.#events=createArcaneEventSource(this,{
291
+ source:'screen-capture',
292
+ eventTypes:Object.freeze(Object.values(SCREEN_CAPTURE_EVENT_TYPES))
293
+ });
294
+ }
295
+
296
+ addEventListener(type,listener,options){return this.#events.addEventListener(type,listener,options);}
297
+ removeEventListener(type,listener,options){return this.#events.removeEventListener(type,listener,options);}
298
+ on(type,listener,options){return this.#events.on(type,listener,options);}
299
+ subscribe(type,listener,options){return this.#events.subscribe(type,listener,options);}
300
+ dispatchEvent(value){return this.#events.dispatchEvent(value);}
301
+
302
+ available(){
303
+ return !this.#disposed
304
+ &&!this.#disposing
305
+ &&Boolean(this.mediaDevices?.getDisplayMedia&&this.document);
306
+ }
307
+
308
+ #disposedError(){
309
+ return captureError(
310
+ SCREEN_CAPTURE_ERRORS.disposed,
311
+ 'The screen capture owner has been disposed.'
312
+ );
313
+ }
314
+
315
+ #assertOpen(){
316
+ if(this.#disposed||this.#disposing)throw this.#disposedError();
317
+ }
318
+
319
+ assertIdle(){
320
+ this.#assertOpen();
321
+ if(this.#operation){
322
+ throw captureError(
323
+ SCREEN_CAPTURE_ERRORS.active,
324
+ 'Stop the active screen capture before starting another one.'
325
+ );
326
+ }
327
+ return true;
328
+ }
329
+
330
+ #linkSignal(signal,controller,cleanup){
331
+ if(signal===undefined||signal===null)return;
332
+ function abortLinkedScreenCapture(){
333
+ if(!controller.signal.aborted)controller.abort(signal.reason);
334
+ }
335
+ if(signal.aborted){
336
+ abortLinkedScreenCapture();
337
+ return;
338
+ }
339
+ signal.addEventListener('abort',abortLinkedScreenCapture,{once:true});
340
+ cleanup.push(function removeLinkedScreenCaptureAbort(){
341
+ signal.removeEventListener('abort',abortLinkedScreenCapture);
342
+ });
343
+ }
344
+
345
+ async acquire(optionsValue={}){
346
+ this.#assertOpen();
347
+ const options=optionsRecord(optionsValue,'Screen capture acquisition options');
348
+ if(!this.available()){
349
+ throw captureError(
350
+ SCREEN_CAPTURE_ERRORS.displayUnavailable,
351
+ 'Display capture is unavailable in this browser.'
352
+ );
353
+ }
354
+ if(options.signal?.aborted)throw abortError(options.signal.reason);
355
+ const controller=new AbortController();
356
+ const cleanup=[];
357
+ this.#linkSignal(options.signal??null,controller,cleanup);
358
+ const acquisition={controller,cleanup,returned:false};
359
+ this.#acquisitions.add(acquisition);
360
+ let pending;
361
+ try{
362
+ pending=Promise.resolve(this.mediaDevices.getDisplayMedia({
363
+ video:{frameRate:{ideal:15,max:30}},
364
+ audio:Boolean(options.audio)
365
+ }));
366
+ }catch(cause){
367
+ this.#acquisitions.delete(acquisition);
368
+ for(const remove of cleanup.splice(0))remove();
369
+ throw captureError(
370
+ SCREEN_CAPTURE_ERRORS.displaySelectionRejected,
371
+ 'The display-capture request was rejected before display selection began.',
372
+ cause
373
+ );
374
+ }
375
+ const owner=this;
376
+ return new Promise(function waitForDisplaySelection(resolve,reject){
377
+ function finish(){
378
+ owner.#acquisitions.delete(acquisition);
379
+ for(const remove of cleanup.splice(0))remove();
380
+ controller.signal.removeEventListener('abort',rejectAbortedAcquisition);
381
+ }
382
+ function rejectAbortedAcquisition(){
383
+ if(acquisition.returned)return;
384
+ acquisition.returned=true;
385
+ reject(owner.#disposed||owner.#disposing
386
+ ?owner.#disposedError()
387
+ :abortError(controller.signal.reason));
388
+ }
389
+ controller.signal.addEventListener('abort',rejectAbortedAcquisition,{once:true});
390
+ if(controller.signal.aborted)rejectAbortedAcquisition();
391
+ pending.then(
392
+ function resolveDisplayStream(stream){
393
+ const stale=acquisition.returned
394
+ ||controller.signal.aborted
395
+ ||owner.#disposed
396
+ ||owner.#disposing;
397
+ if(stale){
398
+ try{owner.stopTracks(stream);}catch(error){reportDetachedError(error);}
399
+ }else{
400
+ acquisition.returned=true;
401
+ resolve(stream);
402
+ }
403
+ finish();
404
+ },
405
+ function rejectDisplaySelection(cause){
406
+ if(!acquisition.returned){
407
+ acquisition.returned=true;
408
+ reject(captureError(
409
+ SCREEN_CAPTURE_ERRORS.displaySelectionRejected,
410
+ 'The display-capture request was rejected.',
411
+ cause
412
+ ));
413
+ }
414
+ finish();
415
+ }
416
+ );
417
+ });
418
+ }
419
+
420
+ #nextOperationId(mode,requested){
421
+ if(requested)return requested;
422
+ this.#operationSequence+=1;
423
+ return this.#events.instanceId+':'+mode+':'+this.#operationSequence.toString(36);
424
+ }
425
+
426
+ #publicDetail(detail,{code=null,reason,status}){
427
+ const blob=detail?.blob;
428
+ return Object.freeze({
429
+ ...(typeof detail?.mode==='string'?{mode:detail.mode}:{}),
430
+ ...(typeof detail?.mimeType==='string'?{mimeType:detail.mimeType}:{}),
431
+ ...(typeof detail?.extension==='string'?{extension:detail.extension}:{}),
432
+ ...(Number.isFinite(detail?.duration)?{duration:Math.max(0,detail.duration)}:{}),
433
+ ...(Number.isSafeInteger(detail?.width)?{width:detail.width}:{}),
434
+ ...(Number.isSafeInteger(detail?.height)?{height:detail.height}:{}),
435
+ ...(Number.isSafeInteger(blob?.size)?{byteCount:blob.size}:{}),
436
+ ...(code?{code}:{}),
437
+ reason,
438
+ status
439
+ });
440
+ }
441
+
442
+ #publish(operation,type,detail,{code=null,reason,status,cancelable=false}={}){
443
+ if(this.#events.disposed)return null;
444
+ const compatibilityDetail=Object.freeze({
445
+ ...detail,
446
+ mode:typeof detail?.mode==='string'?detail.mode:operation.mode,
447
+ operationId:operation.id,
448
+ reason,
449
+ ...(code?{code}:{})
450
+ });
451
+ return this.#events.dispatch(
452
+ EVENT_NAMES[type],
453
+ compatibilityDetail,
454
+ {
455
+ operationId:operation.id,
456
+ publicDetail:this.#publicDetail(compatibilityDetail,{code,reason,status}),
457
+ cancelable
458
+ }
459
+ );
460
+ }
461
+
462
+ #beginOperation(mode,{signal=null,operationId=null}={}){
463
+ this.assertIdle();
464
+ if(signal?.aborted)throw abortError(signal.reason);
465
+ const controller=new AbortController();
466
+ const operation={
467
+ abortPublishesError:true,
468
+ cleanup:[],
469
+ completed:false,
470
+ controller,
471
+ errorPublished:false,
472
+ id:this.#nextOperationId(mode,operationId),
473
+ mode,
474
+ phase:'requesting',
475
+ recorderCleanup:[],
476
+ recorderOutcome:null,
477
+ resolveRecorderOutcome:null,
478
+ resultPublished:false,
479
+ stopCallPromise:null,
480
+ stopPublished:false,
481
+ stopReason:null,
482
+ terminalError:null
483
+ };
484
+ this.#operation=operation;
485
+ this.mode=mode;
486
+ const owner=this;
487
+ function abortActiveScreenCapture(){owner.#settleAbortedOperation(operation);}
488
+ controller.signal.addEventListener('abort',abortActiveScreenCapture,{once:true});
489
+ operation.cleanup.push(function removeActiveScreenCaptureAbort(){
490
+ controller.signal.removeEventListener('abort',abortActiveScreenCapture);
491
+ });
492
+ this.#linkSignal(signal,controller,operation.cleanup);
493
+ const publication=this.#publish(
494
+ operation,
495
+ 'requesting',
496
+ Object.freeze({mode}),
497
+ {
498
+ reason:SCREEN_CAPTURE_REASONS.displaySelectionRequested,
499
+ status:SCREEN_CAPTURE_STATUSES.selectingDisplay,
500
+ cancelable:true
501
+ }
502
+ );
503
+ if(publication&&!publication.accepted){
504
+ const error=captureError(
505
+ SCREEN_CAPTURE_ERRORS.displaySelectionCancelled,
506
+ 'Display selection was cancelled by a screen capture listener.'
507
+ );
508
+ this.#requestOperationAbort(operation,error,{
509
+ publishError:true,
510
+ stopReason:error.reason
511
+ });
512
+ throw error;
513
+ }
514
+ return operation;
515
+ }
516
+
517
+ #isCurrent(operation){
518
+ return this.#operation===operation&&!operation.completed;
519
+ }
520
+
521
+ #assertCurrent(operation){
522
+ if(this.#isCurrent(operation)&&!operation.controller.signal.aborted)return;
523
+ throw operation.terminalError
524
+ ??(this.#disposed||this.#disposing?this.#disposedError():abortError());
525
+ }
526
+
527
+ #setStream(operation,stream){
528
+ this.#assertCurrent(operation);
529
+ operation.stream=stream;
530
+ this.stream=stream;
531
+ this.#attachTrackEnd(operation,stream);
532
+ }
533
+
534
+ #attachTrackEnd(operation,stream){
535
+ for(const track of stream?.getTracks?.()||[]){
536
+ if(typeof track?.addEventListener!=='function')continue;
537
+ const owner=this;
538
+ function stopEndedScreenCaptureTrack(){
539
+ if(!owner.#isCurrent(operation))return;
540
+ if(operation.phase==='recording'){
541
+ owner.stop().catch(reportDetachedError);
542
+ return;
543
+ }
544
+ if(operation.phase==='stopping'
545
+ ||operation.phase==='completing'
546
+ ||operation.phase==='aborting'
547
+ ||operation.phase==='failing')return;
548
+ const error=captureError(
549
+ SCREEN_CAPTURE_ERRORS.displayTrackEndedBeforeRecording,
550
+ 'The selected display track ended before capture recording began.'
551
+ );
552
+ owner.#requestOperationAbort(operation,error,{
553
+ publishError:true,
554
+ stopReason:error.reason
555
+ });
556
+ }
557
+ track.addEventListener('ended',stopEndedScreenCaptureTrack,{once:true});
558
+ operation.cleanup.push(function removeScreenCaptureTrackEnd(){
559
+ track.removeEventListener?.('ended',stopEndedScreenCaptureTrack);
560
+ });
561
+ if(track.readyState==='ended'){
562
+ stopEndedScreenCaptureTrack();
563
+ if(!this.#isCurrent(operation))break;
564
+ }
565
+ }
566
+ }
567
+
568
+ #setPreparedSurface(operation,{video,canvas}){
569
+ this.#assertCurrent(operation);
570
+ operation.video=video;
571
+ operation.canvas=canvas;
572
+ this.video=video;
573
+ this.canvas=canvas;
574
+ }
575
+
576
+ #removeRecorderListeners(operation){
577
+ for(const remove of operation.recorderCleanup.splice(0))remove();
578
+ }
579
+
580
+ #settleRecorder(operation,outcome){
581
+ if(!operation.resolveRecorderOutcome)return false;
582
+ const resolve=operation.resolveRecorderOutcome;
583
+ operation.resolveRecorderOutcome=null;
584
+ operation.recorderOutcome=outcome;
585
+ resolve(outcome);
586
+ return true;
587
+ }
588
+
589
+ #cleanupResources(operation,{settleRecorderError=null}={}){
590
+ let firstError=null;
591
+ function remember(error){firstError??=error;}
592
+ clearInterval(operation.timer??0);
593
+ clearInterval(this.timer);
594
+ operation.timer=0;
595
+ this.timer=0;
596
+ if(settleRecorderError)this.#settleRecorder(operation,{error:settleRecorderError});
597
+ const recorder=operation.recorder??this.recorder;
598
+ if(recorder&&recorder.state!=='inactive'){
599
+ try{recorder.stop();}catch(error){remember(error);}
600
+ }
601
+ this.#removeRecorderListeners(operation);
602
+ try{this.stopTracks(operation.stream??this.stream);}catch(error){remember(error);}
603
+ const video=operation.video??this.video;
604
+ if(video){
605
+ try{video.pause?.();}catch(error){remember(error);}
606
+ try{video.srcObject=null;}catch(error){remember(error);}
607
+ }
608
+ return firstError;
609
+ }
610
+
611
+ #releaseOperation(operation){
612
+ if(operation.completed)return false;
613
+ operation.completed=true;
614
+ for(const remove of operation.cleanup.splice(0))remove();
615
+ this.#removeRecorderListeners(operation);
616
+ if(this.#operation===operation)this.#operation=null;
617
+ this.mode='idle';
618
+ this.stream=null;
619
+ this.recorder=null;
620
+ this.startedAt=0;
621
+ this.frames=[];
622
+ this.timer=0;
623
+ this.video=null;
624
+ this.canvas=null;
625
+ this.stopPromise=null;
626
+ this.gifDelay=0;
627
+ return true;
628
+ }
629
+
630
+ #publishFailure(operation,error){
631
+ if(operation.errorPublished||this.#events.disposed)return false;
632
+ operation.errorPublished=true;
633
+ this.#publish(
634
+ operation,
635
+ 'error',
636
+ Object.freeze({error,mode:operation.mode}),
637
+ {
638
+ code:error.code,
639
+ reason:error.reason,
640
+ status:SCREEN_CAPTURE_STATUSES.captureRejected
641
+ }
642
+ );
643
+ return true;
644
+ }
645
+
646
+ #publishStop(operation,reason,code=null){
647
+ if(operation.stopPublished||this.#events.disposed)return false;
648
+ operation.stopPublished=true;
649
+ this.#publish(
650
+ operation,
651
+ 'stop',
652
+ Object.freeze({mode:operation.mode}),
653
+ {code,reason,status:SCREEN_CAPTURE_STATUSES.captureStopped}
654
+ );
655
+ return true;
656
+ }
657
+
658
+ #requestOperationAbort(operation,error,{
659
+ publishError=true,
660
+ stopReason=error.reason
661
+ }={}){
662
+ if(!this.#isCurrent(operation))return false;
663
+ operation.terminalError=error;
664
+ operation.abortPublishesError=publishError;
665
+ operation.stopReason=stopReason;
666
+ if(!operation.controller.signal.aborted)operation.controller.abort(error);
667
+ else this.#settleAbortedOperation(operation);
668
+ return true;
669
+ }
670
+
671
+ #settleAbortedOperation(operation){
672
+ if(!this.#isCurrent(operation))return false;
673
+ const error=operation.terminalError
674
+ ??(this.#disposed||this.#disposing
675
+ ?this.#disposedError()
676
+ :abortError(operation.controller.signal.reason));
677
+ operation.terminalError=error;
678
+ operation.phase='aborting';
679
+ const cleanupError=this.#cleanupResources(operation,{settleRecorderError:error});
680
+ if(cleanupError&&!Object.hasOwn(error,'cleanupCause'))error.cleanupCause=cleanupError;
681
+ if(operation.abortPublishesError)this.#publishFailure(operation,error);
682
+ this.#publishStop(operation,operation.stopReason??error.reason,error.code);
683
+ this.#releaseOperation(operation);
684
+ return true;
685
+ }
686
+
687
+ #failOperation(operation,error){
688
+ if(!this.#isCurrent(operation))return false;
689
+ operation.terminalError=error;
690
+ operation.phase='failing';
691
+ const cleanupError=this.#cleanupResources(operation,{settleRecorderError:error});
692
+ if(cleanupError&&!Object.hasOwn(error,'cleanupCause'))error.cleanupCause=cleanupError;
693
+ this.#publishFailure(operation,error);
694
+ this.#publishStop(operation,error.reason,error.code);
695
+ this.#releaseOperation(operation);
696
+ return true;
697
+ }
698
+
699
+ #operationError(cause,operation,stage){
700
+ if(operation.terminalError)return operation.terminalError;
701
+ if(cause?.code&&cause?.reason)return cause;
702
+ if(operation.controller.signal.aborted)return abortError(
703
+ operation.controller.signal.reason??cause
704
+ );
705
+ const contracts={
706
+ 'display-selection':[
707
+ SCREEN_CAPTURE_ERRORS.displaySelectionRejected,
708
+ 'The display-capture request was rejected.'
709
+ ],
710
+ 'display-preparation':[
711
+ SCREEN_CAPTURE_ERRORS.displayMetadataRejected,
712
+ 'The selected display could not be prepared for capture.'
713
+ ],
714
+ 'image-draw':[
715
+ SCREEN_CAPTURE_ERRORS.imageDrawRejected,
716
+ 'The selected display frame could not be drawn to the capture canvas.'
717
+ ],
718
+ 'image-encoding':[
719
+ SCREEN_CAPTURE_ERRORS.imageEncodingRejected,
720
+ 'The captured display image could not be encoded.'
721
+ ],
722
+ 'recorder-construction':[
723
+ SCREEN_CAPTURE_ERRORS.recorderConstructionRejected,
724
+ 'The display recording could not create a MediaRecorder.'
725
+ ],
726
+ 'recorder-start':[
727
+ SCREEN_CAPTURE_ERRORS.recorderStartRejected,
728
+ 'The display recording could not start the MediaRecorder.'
729
+ ],
730
+ 'recorder-stop':[
731
+ SCREEN_CAPTURE_ERRORS.recorderStopRejected,
732
+ 'The display recording could not stop the MediaRecorder.'
733
+ ],
734
+ 'gif-frame-read':[
735
+ SCREEN_CAPTURE_ERRORS.gifFrameReadRejected,
736
+ 'A display frame could not be read for GIF capture.'
737
+ ],
738
+ 'gif-encoding':[
739
+ SCREEN_CAPTURE_ERRORS.gifEncodingRejected,
740
+ 'The captured GIF frames could not be encoded.'
741
+ ],
742
+ cleanup:[
743
+ SCREEN_CAPTURE_ERRORS.cleanupRejected,
744
+ 'The screen capture resources could not be released.'
745
+ ]
746
+ };
747
+ const selected=contracts[stage]??contracts.cleanup;
748
+ return captureError(selected[0],selected[1],cause);
749
+ }
750
+
751
+ async captureImage(optionsValue={}){
752
+ const options=optionsRecord(optionsValue,'Image capture options');
753
+ const operation=this.#beginOperation('image',{
754
+ signal:options.signal??null,
755
+ operationId:admittedOperationId(options.operationId)
756
+ });
757
+ let stage='display-selection';
758
+ try{
759
+ const stream=await this.acquire({audio:false,signal:operation.controller.signal});
760
+ this.#setStream(operation,stream);
761
+ stage='display-preparation';
762
+ const prepared=await this.prepare(
763
+ stream,
764
+ normalizedWidth(options.maxWidth,1920),
765
+ {signal:operation.controller.signal}
766
+ );
767
+ this.#setPreparedSurface(operation,prepared);
768
+ const context=prepared.canvas.getContext?.('2d');
769
+ if(!context){
770
+ throw captureError(
771
+ SCREEN_CAPTURE_ERRORS.imageCanvasUnavailable,
772
+ 'The image capture canvas does not expose a 2D context.'
773
+ );
774
+ }
775
+ stage='image-draw';
776
+ context.drawImage(
777
+ prepared.video,
778
+ 0,
779
+ 0,
780
+ prepared.canvas.width,
781
+ prepared.canvas.height
782
+ );
783
+ this.#assertCurrent(operation);
784
+ stage='image-encoding';
785
+ const type=typeof options.type==='string'&&options.type.trim()
786
+ ?options.type.trim()
787
+ :'image/png';
788
+ const blob=await this.#canvasBlob(
789
+ prepared.canvas,
790
+ type,
791
+ options.quality,
792
+ operation.controller.signal
793
+ );
794
+ this.#assertCurrent(operation);
795
+ const encodedType=imageResultType(blob);
796
+ const result={
797
+ blob,
798
+ mimeType:encodedType.mimeType,
799
+ extension:encodedType.extension,
800
+ duration:0,
801
+ width:prepared.canvas.width,
802
+ height:prepared.canvas.height
803
+ };
804
+ operation.phase='completing';
805
+ const cleanupError=this.#cleanupResources(operation);
806
+ if(cleanupError)throw this.#operationError(cleanupError,operation,'cleanup');
807
+ this.#publish(
808
+ operation,
809
+ 'result',
810
+ result,
811
+ {
812
+ reason:SCREEN_CAPTURE_REASONS.imageCompleted,
813
+ status:SCREEN_CAPTURE_STATUSES.captureReady
814
+ }
815
+ );
816
+ operation.resultPublished=true;
817
+ this.#releaseOperation(operation);
818
+ return result;
819
+ }catch(cause){
820
+ const error=this.#operationError(cause,operation,stage);
821
+ if(this.#isCurrent(operation))this.#failOperation(operation,error);
822
+ throw operation.terminalError??error;
823
+ }
824
+ }
825
+
826
+ #configureRecorder(operation,recorder,mimeType){
827
+ operation.recorder=recorder;
828
+ this.recorder=recorder;
829
+ const chunks=[];
830
+ operation.stopPromise=new Promise(function createRecorderOutcome(resolve){
831
+ operation.resolveRecorderOutcome=resolve;
832
+ });
833
+ this.stopPromise=operation.stopPromise;
834
+ const owner=this;
835
+ function receiveRecordedData(event){
836
+ if(event.data?.size)chunks.push(event.data);
837
+ }
838
+ function receiveRecorderStop(){
839
+ let outcome;
840
+ try{
841
+ const type=recorder.mimeType||mimeType||'video/webm';
842
+ outcome={
843
+ result:{
844
+ blob:new Blob(chunks,{type}),
845
+ mimeType:type,
846
+ extension:type.includes('mp4')?'mp4':'webm',
847
+ duration:Math.max(0,Date.now()-operation.startedAt)
848
+ }
849
+ };
850
+ }catch(cause){
851
+ outcome={
852
+ error:captureError(
853
+ SCREEN_CAPTURE_ERRORS.recorderStopRejected,
854
+ 'The display recording could not assemble the recorded media.',
855
+ cause
856
+ )
857
+ };
858
+ }
859
+ owner.#settleRecorder(operation,outcome);
860
+ if(operation.phase==='recording'&&owner.#isCurrent(operation)){
861
+ if(outcome.error)owner.#failOperation(operation,outcome.error);
862
+ else owner.#completeDetachedVideo(operation,outcome.result);
863
+ }
864
+ }
865
+ function receiveRecorderError(event){
866
+ const error=captureError(
867
+ SCREEN_CAPTURE_ERRORS.recorderErrorReceived,
868
+ 'The MediaRecorder reported a display-recording error.',
869
+ event?.error??event
870
+ );
871
+ owner.#settleRecorder(operation,{error});
872
+ if(operation.phase!=='stopping'&&owner.#isCurrent(operation)){
873
+ owner.#failOperation(operation,error);
874
+ }
875
+ }
876
+ recorder.addEventListener('dataavailable',receiveRecordedData);
877
+ recorder.addEventListener('stop',receiveRecorderStop,{once:true});
878
+ recorder.addEventListener('error',receiveRecorderError,{once:true});
879
+ operation.recorderCleanup.push(
880
+ function removeRecordedDataListener(){
881
+ recorder.removeEventListener?.('dataavailable',receiveRecordedData);
882
+ },
883
+ function removeRecorderStopListener(){
884
+ recorder.removeEventListener?.('stop',receiveRecorderStop);
885
+ },
886
+ function removeRecorderErrorListener(){
887
+ recorder.removeEventListener?.('error',receiveRecorderError);
888
+ }
889
+ );
890
+ }
891
+
892
+ #completeDetachedVideo(operation,result){
893
+ if(!this.#isCurrent(operation))return false;
894
+ operation.phase='completing';
895
+ const cleanupError=this.#cleanupResources(operation);
896
+ if(cleanupError){
897
+ this.#failOperation(
898
+ operation,
899
+ this.#operationError(cleanupError,operation,'cleanup')
900
+ );
901
+ return false;
902
+ }
903
+ this.#publish(
904
+ operation,
905
+ 'result',
906
+ result,
907
+ {
908
+ reason:SCREEN_CAPTURE_REASONS.videoCompleted,
909
+ status:SCREEN_CAPTURE_STATUSES.captureReady
910
+ }
911
+ );
912
+ operation.resultPublished=true;
913
+ this.#publishStop(operation,SCREEN_CAPTURE_REASONS.videoRecordingStopped);
914
+ this.#releaseOperation(operation);
915
+ return true;
916
+ }
917
+
918
+ async startVideo(optionsValue={}){
919
+ const options=optionsRecord(optionsValue,'Video capture options');
920
+ this.#assertOpen();
921
+ if(!this.Recorder){
922
+ throw captureError(
923
+ SCREEN_CAPTURE_ERRORS.recorderUnavailable,
924
+ 'Video recording is unavailable in this browser.'
925
+ );
926
+ }
927
+ const operation=this.#beginOperation('video',{
928
+ signal:options.signal??null,
929
+ operationId:admittedOperationId(options.operationId)
930
+ });
931
+ let stage='display-selection';
932
+ try{
933
+ const stream=await this.acquire({
934
+ audio:options.audio===undefined?true:Boolean(options.audio),
935
+ signal:operation.controller.signal
936
+ });
937
+ this.#setStream(operation,stream);
938
+ const mimeType=supportedRecorderType(this.Recorder);
939
+ stage='recorder-construction';
940
+ const recorder=new this.Recorder(stream,mimeType?{mimeType}:undefined);
941
+ this.#configureRecorder(operation,recorder,mimeType);
942
+ operation.startedAt=Date.now();
943
+ this.startedAt=operation.startedAt;
944
+ operation.phase='recording';
945
+ stage='recorder-start';
946
+ recorder.start(500);
947
+ if(operation.completed){
948
+ if(operation.terminalError)throw operation.terminalError;
949
+ if(operation.resultPublished)return true;
950
+ throw captureError(
951
+ SCREEN_CAPTURE_ERRORS.recorderStopRejected,
952
+ 'The MediaRecorder completed during start without a recorded media result.'
953
+ );
954
+ }
955
+ this.#assertCurrent(operation);
956
+ this.#publish(
957
+ operation,
958
+ 'start',
959
+ Object.freeze({mode:'video'}),
960
+ {
961
+ reason:SCREEN_CAPTURE_REASONS.videoRecordingStarted,
962
+ status:SCREEN_CAPTURE_STATUSES.recording
963
+ }
964
+ );
965
+ return true;
966
+ }catch(cause){
967
+ const error=this.#operationError(cause,operation,stage);
968
+ if(this.#isCurrent(operation))this.#failOperation(operation,error);
969
+ throw operation.terminalError??error;
970
+ }
971
+ }
972
+
973
+ #sampleGifFrame(operation,context){
974
+ context.drawImage(
975
+ operation.video,
976
+ 0,
977
+ 0,
978
+ operation.canvas.width,
979
+ operation.canvas.height
980
+ );
981
+ operation.frames.push(context.getImageData(
982
+ 0,
983
+ 0,
984
+ operation.canvas.width,
985
+ operation.canvas.height
986
+ ));
987
+ this.frames=operation.frames;
988
+ }
989
+
990
+ async startGif(optionsValue={}){
991
+ const options=optionsRecord(optionsValue,'GIF capture options');
992
+ const operation=this.#beginOperation('gif',{
993
+ signal:options.signal??null,
994
+ operationId:admittedOperationId(options.operationId)
995
+ });
996
+ let stage='display-selection';
997
+ try{
998
+ const stream=await this.acquire({audio:false,signal:operation.controller.signal});
999
+ this.#setStream(operation,stream);
1000
+ stage='display-preparation';
1001
+ const prepared=await this.prepare(
1002
+ stream,
1003
+ normalizedWidth(options.maxWidth,640),
1004
+ {signal:operation.controller.signal}
1005
+ );
1006
+ this.#setPreparedSurface(operation,prepared);
1007
+ const context=prepared.canvas.getContext?.('2d',{willReadFrequently:true});
1008
+ if(!context){
1009
+ throw captureError(
1010
+ SCREEN_CAPTURE_ERRORS.imageCanvasUnavailable,
1011
+ 'The GIF capture canvas does not expose a 2D context.'
1012
+ );
1013
+ }
1014
+ operation.frames=[];
1015
+ operation.gifDelay=Math.max(100,Number(options.frameDelay)||250);
1016
+ operation.startedAt=Date.now();
1017
+ this.frames=operation.frames;
1018
+ this.gifDelay=operation.gifDelay;
1019
+ this.startedAt=operation.startedAt;
1020
+ stage='gif-frame-read';
1021
+ this.#sampleGifFrame(operation,context);
1022
+ operation.phase='recording';
1023
+ const owner=this;
1024
+ function sampleScreenCaptureGifFrame(){
1025
+ if(!owner.#isCurrent(operation)||operation.phase!=='recording')return;
1026
+ try{owner.#sampleGifFrame(operation,context);}
1027
+ catch(cause){
1028
+ owner.#failOperation(
1029
+ operation,
1030
+ owner.#operationError(cause,operation,'gif-frame-read')
1031
+ );
1032
+ }
1033
+ }
1034
+ operation.timer=setInterval(sampleScreenCaptureGifFrame,operation.gifDelay);
1035
+ this.timer=operation.timer;
1036
+ this.#publish(
1037
+ operation,
1038
+ 'start',
1039
+ Object.freeze({mode:'gif'}),
1040
+ {
1041
+ reason:SCREEN_CAPTURE_REASONS.gifRecordingStarted,
1042
+ status:SCREEN_CAPTURE_STATUSES.recording
1043
+ }
1044
+ );
1045
+ return true;
1046
+ }catch(cause){
1047
+ const error=this.#operationError(cause,operation,stage);
1048
+ if(this.#isCurrent(operation))this.#failOperation(operation,error);
1049
+ throw operation.terminalError??error;
1050
+ }
1051
+ }
1052
+
1053
+ async #stopRecording(operation,options){
1054
+ if(options.signal?.aborted){
1055
+ const error=abortError(options.signal.reason);
1056
+ this.#requestOperationAbort(operation,error,{
1057
+ publishError:true,
1058
+ stopReason:error.reason
1059
+ });
1060
+ throw error;
1061
+ }
1062
+ this.#linkSignal(options.signal??null,operation.controller,operation.cleanup);
1063
+ if(operation.phase==='requesting'||operation.mode==='image'){
1064
+ const error=captureError(
1065
+ SCREEN_CAPTURE_ERRORS.operationStopped,
1066
+ 'The screen capture operation was stopped before completion.'
1067
+ );
1068
+ this.#requestOperationAbort(operation,error,{
1069
+ publishError:false,
1070
+ stopReason:error.reason
1071
+ });
1072
+ return null;
1073
+ }
1074
+ operation.phase='stopping';
1075
+ let stage=operation.mode==='video'?'recorder-stop':'gif-encoding';
1076
+ try{
1077
+ let result;
1078
+ if(operation.mode==='video'){
1079
+ const recorder=operation.recorder;
1080
+ if(!recorder){
1081
+ throw captureError(
1082
+ SCREEN_CAPTURE_ERRORS.recorderStopRejected,
1083
+ 'The active display recording has no MediaRecorder.'
1084
+ );
1085
+ }
1086
+ if(recorder.state!=='inactive')recorder.stop();
1087
+ const outcome=operation.recorderOutcome??await operation.stopPromise;
1088
+ this.#assertCurrent(operation);
1089
+ if(outcome?.error)throw outcome.error;
1090
+ result=outcome?.result;
1091
+ if(!result){
1092
+ throw captureError(
1093
+ SCREEN_CAPTURE_ERRORS.recorderStopRejected,
1094
+ 'The MediaRecorder stopped without a recorded media result.'
1095
+ );
1096
+ }
1097
+ }else{
1098
+ clearInterval(operation.timer);
1099
+ operation.timer=0;
1100
+ this.timer=0;
1101
+ const encoder=new GifEncoder(operation.canvas.width,operation.canvas.height);
1102
+ for(const frame of operation.frames){
1103
+ encoder.addFrame(frame,{delay:operation.gifDelay});
1104
+ }
1105
+ result={
1106
+ blob:encoder.encode(),
1107
+ mimeType:'image/gif',
1108
+ extension:'gif',
1109
+ duration:Math.max(0,Date.now()-operation.startedAt),
1110
+ width:operation.canvas.width,
1111
+ height:operation.canvas.height
1112
+ };
1113
+ }
1114
+ this.#assertCurrent(operation);
1115
+ stage='cleanup';
1116
+ const cleanupError=this.#cleanupResources(operation);
1117
+ if(cleanupError)throw cleanupError;
1118
+ const resultReason=operation.mode==='video'
1119
+ ?SCREEN_CAPTURE_REASONS.videoCompleted
1120
+ :SCREEN_CAPTURE_REASONS.gifCompleted;
1121
+ this.#publish(
1122
+ operation,
1123
+ 'result',
1124
+ result,
1125
+ {reason:resultReason,status:SCREEN_CAPTURE_STATUSES.captureReady}
1126
+ );
1127
+ operation.resultPublished=true;
1128
+ this.#publishStop(
1129
+ operation,
1130
+ operation.mode==='video'
1131
+ ?SCREEN_CAPTURE_REASONS.videoRecordingStopped
1132
+ :SCREEN_CAPTURE_REASONS.gifRecordingStopped
1133
+ );
1134
+ this.#releaseOperation(operation);
1135
+ return result;
1136
+ }catch(cause){
1137
+ const error=this.#operationError(cause,operation,stage);
1138
+ if(this.#isCurrent(operation))this.#failOperation(operation,error);
1139
+ throw operation.terminalError??error;
1140
+ }
1141
+ }
1142
+
1143
+ async stop(optionsValue={}){
1144
+ this.#assertOpen();
1145
+ const options=optionsRecord(optionsValue,'Screen capture stop options');
1146
+ const operation=this.#operation;
1147
+ if(!operation)return null;
1148
+ if(operation.stopCallPromise)return operation.stopCallPromise;
1149
+ operation.stopCallPromise=this.#stopRecording(operation,options);
1150
+ return operation.stopCallPromise;
1151
+ }
1152
+
1153
+ async prepare(stream,maxWidth,optionsValue={}){
1154
+ this.#assertOpen();
1155
+ const options=optionsRecord(optionsValue,'Screen capture display options');
1156
+ if(options.signal?.aborted)throw abortError(options.signal.reason);
1157
+ let video;
1158
+ try{
1159
+ video=this.document.createElement('video');
1160
+ video.muted=true;
1161
+ video.playsInline=true;
1162
+ video.srcObject=stream;
1163
+ }catch(cause){
1164
+ throw captureError(
1165
+ SCREEN_CAPTURE_ERRORS.displayMetadataRejected,
1166
+ 'The selected display could not be attached to a video surface.',
1167
+ cause
1168
+ );
1169
+ }
1170
+ try{
1171
+ await new Promise(function waitForDisplayMetadata(resolve,reject){
1172
+ let settled=false;
1173
+ function cleanup(){
1174
+ video.removeEventListener?.('loadedmetadata',receiveDisplayMetadata);
1175
+ video.removeEventListener?.('error',rejectDisplayMetadata);
1176
+ options.signal?.removeEventListener?.('abort',abortDisplayMetadata);
1177
+ }
1178
+ function settle(callback,value){
1179
+ if(settled)return;
1180
+ settled=true;
1181
+ cleanup();
1182
+ callback(value);
1183
+ }
1184
+ function receiveDisplayMetadata(){settle(resolve);}
1185
+ function rejectDisplayMetadata(event){
1186
+ settle(reject,captureError(
1187
+ SCREEN_CAPTURE_ERRORS.displayMetadataRejected,
1188
+ 'The selected display metadata could not be read.',
1189
+ event
1190
+ ));
1191
+ }
1192
+ function abortDisplayMetadata(){
1193
+ settle(reject,abortError(options.signal?.reason));
1194
+ }
1195
+ video.addEventListener('loadedmetadata',receiveDisplayMetadata,{once:true});
1196
+ video.addEventListener('error',rejectDisplayMetadata,{once:true});
1197
+ options.signal?.addEventListener?.('abort',abortDisplayMetadata,{once:true});
1198
+ if(options.signal?.aborted)abortDisplayMetadata();
1199
+ });
1200
+ if(options.signal?.aborted)throw abortError(options.signal.reason);
1201
+ try{await this.#awaitWithSignal(video.play(),options.signal??null);}
1202
+ catch(cause){
1203
+ if(cause?.code===SCREEN_CAPTURE_ERRORS.operationAborted.code
1204
+ &&cause?.reason===SCREEN_CAPTURE_ERRORS.operationAborted.reason){
1205
+ throw cause;
1206
+ }
1207
+ throw captureError(
1208
+ SCREEN_CAPTURE_ERRORS.displayPlaybackRejected,
1209
+ 'The selected display could not start playback for capture.',
1210
+ cause
1211
+ );
1212
+ }
1213
+ if(options.signal?.aborted)throw abortError(options.signal.reason);
1214
+ const width=Number(video.videoWidth);
1215
+ const height=Number(video.videoHeight);
1216
+ if(!Number.isFinite(width)||width<=0||!Number.isFinite(height)||height<=0){
1217
+ throw captureError(
1218
+ SCREEN_CAPTURE_ERRORS.displayMetadataRejected,
1219
+ 'The selected display reported invalid frame dimensions.'
1220
+ );
1221
+ }
1222
+ const scale=Math.min(1,normalizedWidth(maxWidth,1920)/width);
1223
+ const canvas=this.document.createElement('canvas');
1224
+ canvas.width=Math.max(1,Math.round(width*scale));
1225
+ canvas.height=Math.max(1,Math.round(height*scale));
1226
+ return {video,canvas};
1227
+ }catch(error){
1228
+ let cleanupFailure=null;
1229
+ try{video.pause?.();}catch(cause){cleanupFailure??=cause;}
1230
+ try{video.srcObject=null;}catch(cause){cleanupFailure??=cause;}
1231
+ if(cleanupFailure&&!Object.hasOwn(error,'cleanupCause')){
1232
+ error.cleanupCause=cleanupFailure;
1233
+ }
1234
+ throw error;
1235
+ }
1236
+ }
1237
+
1238
+ #awaitWithSignal(value,signal){
1239
+ if(signal?.aborted)return Promise.reject(abortError(signal.reason));
1240
+ return new Promise(function waitForAbortableSettlement(resolve,reject){
1241
+ let settled=false;
1242
+ function cleanup(){signal?.removeEventListener?.('abort',abortAwaitedOperation);}
1243
+ function settle(callback,result){
1244
+ if(settled)return;
1245
+ settled=true;
1246
+ cleanup();
1247
+ callback(result);
1248
+ }
1249
+ function abortAwaitedOperation(){settle(reject,abortError(signal?.reason));}
1250
+ signal?.addEventListener?.('abort',abortAwaitedOperation,{once:true});
1251
+ Promise.resolve(value).then(
1252
+ function resolveAwaitedOperation(result){settle(resolve,result);},
1253
+ function rejectAwaitedOperation(error){settle(reject,error);}
1254
+ );
1255
+ });
1256
+ }
1257
+
1258
+ #canvasBlob(canvas,type,quality,signal){
1259
+ if(signal?.aborted)return Promise.reject(abortError(signal.reason));
1260
+ return new Promise(function waitForCanvasEncoding(resolve,reject){
1261
+ let settled=false;
1262
+ function cleanup(){signal?.removeEventListener?.('abort',abortImageEncoding);}
1263
+ function settle(callback,value){
1264
+ if(settled)return;
1265
+ settled=true;
1266
+ cleanup();
1267
+ callback(value);
1268
+ }
1269
+ function abortImageEncoding(){settle(reject,abortError(signal?.reason));}
1270
+ signal?.addEventListener?.('abort',abortImageEncoding,{once:true});
1271
+ try{
1272
+ canvas.toBlob(
1273
+ function receiveEncodedCapture(blob){
1274
+ if(blob)settle(resolve,blob);
1275
+ else settle(reject,captureError(
1276
+ SCREEN_CAPTURE_ERRORS.imageEncodingRejected,
1277
+ 'The capture canvas returned no encoded image.'
1278
+ ));
1279
+ },
1280
+ type,
1281
+ quality
1282
+ );
1283
+ }catch(cause){
1284
+ settle(reject,captureError(
1285
+ SCREEN_CAPTURE_ERRORS.imageEncodingRejected,
1286
+ 'The capture canvas rejected image encoding.',
1287
+ cause
1288
+ ));
1289
+ }
1290
+ });
1291
+ }
1292
+
1293
+ stopTracks(stream){
1294
+ let firstError=null;
1295
+ for(const track of stream?.getTracks?.()||[]){
1296
+ try{track.stop();}catch(error){firstError??=error;}
1297
+ }
1298
+ if(firstError)throw firstError;
1299
+ return true;
1300
+ }
1301
+
1302
+ reset(){
1303
+ if(this.#disposed||this.#disposing)return false;
1304
+ const operation=this.#operation;
1305
+ if(!operation)return false;
1306
+ const error=captureError(
1307
+ SCREEN_CAPTURE_ERRORS.reset,
1308
+ 'The active screen capture was reset.'
1309
+ );
1310
+ return this.#requestOperationAbort(operation,error,{
1311
+ publishError:false,
1312
+ stopReason:error.reason
1313
+ });
1314
+ }
1315
+
1316
+ emit(type,detail={}){
1317
+ this.#assertOpen();
1318
+ if(!EVENT_NAMES[type]){
1319
+ throw captureError(
1320
+ SCREEN_CAPTURE_ERRORS.eventTypeUndeclared,
1321
+ 'Screen capture events must be requesting, start, result, error, or stop.',
1322
+ undefined,
1323
+ TypeError
1324
+ );
1325
+ }
1326
+ const mode=typeof detail?.mode==='string'
1327
+ ?detail.mode
1328
+ :(this.#operation?.mode??'capture');
1329
+ const operation=this.#operation??{
1330
+ id:this.#nextOperationId(mode,null),
1331
+ mode
1332
+ };
1333
+ const reason=type==='requesting'
1334
+ ?SCREEN_CAPTURE_REASONS.displaySelectionRequested
1335
+ :type==='start'
1336
+ ?mode==='gif'
1337
+ ?SCREEN_CAPTURE_REASONS.gifRecordingStarted
1338
+ :SCREEN_CAPTURE_REASONS.videoRecordingStarted
1339
+ :type==='result'
1340
+ ?mode==='image'
1341
+ ?SCREEN_CAPTURE_REASONS.imageCompleted
1342
+ :mode==='gif'
1343
+ ?SCREEN_CAPTURE_REASONS.gifCompleted
1344
+ :SCREEN_CAPTURE_REASONS.videoCompleted
1345
+ :type==='stop'
1346
+ ?mode==='gif'
1347
+ ?SCREEN_CAPTURE_REASONS.gifRecordingStopped
1348
+ :SCREEN_CAPTURE_REASONS.videoRecordingStopped
1349
+ :detail?.error?.reason??SCREEN_CAPTURE_ERRORS.cleanupRejected.reason;
1350
+ const status=type==='requesting'
1351
+ ?SCREEN_CAPTURE_STATUSES.selectingDisplay
1352
+ :type==='start'
1353
+ ?SCREEN_CAPTURE_STATUSES.recording
1354
+ :type==='result'
1355
+ ?SCREEN_CAPTURE_STATUSES.captureReady
1356
+ :type==='error'
1357
+ ?SCREEN_CAPTURE_STATUSES.captureRejected
1358
+ :SCREEN_CAPTURE_STATUSES.captureStopped;
1359
+ return this.#publish(operation,type,detail,{
1360
+ code:detail?.error?.code??null,
1361
+ reason,
1362
+ status,
1363
+ cancelable:type==='requesting'
1364
+ })?.occurrence??null;
1365
+ }
1366
+
1367
+ destroy(){
1368
+ if(this.#disposed||this.#disposing)return false;
1369
+ this.#disposing=true;
1370
+ let cleanupFailure=null;
1371
+ const operation=this.#operation;
1372
+ if(operation){
1373
+ const error=this.#disposedError();
1374
+ this.#requestOperationAbort(operation,error,{
1375
+ publishError:false,
1376
+ stopReason:error.reason
1377
+ });
1378
+ cleanupFailure=operation.terminalError?.cleanupCause??null;
1379
+ }else{
1380
+ try{this.stopTracks(this.stream);}catch(error){cleanupFailure=error;}
1381
+ clearInterval(this.timer);
1382
+ }
1383
+ for(const acquisition of this.#acquisitions){
1384
+ if(!acquisition.controller.signal.aborted){
1385
+ acquisition.controller.abort(this.#disposedError());
1386
+ }
1387
+ }
1388
+ this.#disposed=true;
1389
+ this.#disposing=false;
1390
+ this.#events.dispose();
1391
+ if(cleanupFailure){
1392
+ throw captureError(
1393
+ SCREEN_CAPTURE_ERRORS.cleanupRejected,
1394
+ 'Screen capture disposal could not release every active resource.',
1395
+ cleanupFailure
1396
+ );
1397
+ }
1398
+ return true;
1399
+ }
1400
+
1401
+ dispose(){return this.destroy();}
20
1402
  }