Added WebM conversion feature to recordings player.
Ylian Saint-Hilaire committed
Jul 4, 2021 at 15:53 UTC
b8f7fb16ec36a869628be53c7c5413ba603d448f
4 files changed
+1170
-2
public/scripts/agent-desktop-0.0.2.js
+1
@@ -138,6 +138,7 @@ var CreateAgentRemoteDesktop = function (canvasid, scrolldiv) {
138
for (var i = 0; i < obj.PendingOperations.length; i++) { // && KillDraw < tilesDrawn
139
var Msg = obj.PendingOperations[i];
140
if (Msg[0] == (obj.TilesDrawn + 1)) {
141
+ if (obj.onPreDrawImage != null) obj.onPreDrawImage(); // Notify that we are about to draw on the canvas.
142
if (Msg[1] == 1) { obj.ProcessCopyRectMsg(Msg[2]); }
143
else if (Msg[1] == 2) { obj.Canvas.drawImage(Msg[2], obj.rotX(Msg[3], Msg[4]), obj.rotY(Msg[3], Msg[4])); delete Msg[2]; }
144
obj.PendingOperations.splice(i, 1);
public/scripts/amt-desktop-0.0.2.js
+2
@@ -33,6 +33,7 @@ var CreateAmtRemoteDesktop = function (divid, scrolldiv) {
33
obj.sparecache = {};
34
obj.ZRLEfirst = 1;
35
obj.onScreenSizeChange = null;
36
+ //obj.onPreDrawImage = null;
37
obj.frameRateDelay = 0;
38
// ###BEGIN###{DesktopRotation}
39
obj.noMouseRotate = false;
@@ -179,6 +180,7 @@ var CreateAmtRemoteDesktop = function (divid, scrolldiv) {
180
if (obj.acc.byteLength < 4) return;
181
obj.state = 100 + accview.getUint16(2); // Read the number of tiles that are going to be sent, add 100 and use that as our protocol state.
182
cmdsize = 4;
183
+ if (obj.onPreDrawImage != null) obj.onPreDrawImage(); // Notify that we are about to draw on the canvas.
184
break;
185
case 2: // This is the bell, do nothing.
186
cmdsize = 1;
public/scripts/webm-writer.js
new
+1097
@@ -0,0 +1,1097 @@
1
+/**
2
+ * A tool for presenting an ArrayBuffer as a stream for writing some simple data types.
3
+ *
4
+ * By Nicholas Sherlock
5
+ *
6
+ * Released under the WTFPLv2 https://en.wikipedia.org/wiki/WTFPL
7
+ */
8
+
9
+"use strict";
10
+
11
+(function(){
12
+ /*
13
+ * Create an ArrayBuffer of the given length and present it as a writable stream with methods
14
+ * for writing data in different formats.
15
+ */
16
+ var ArrayBufferDataStream = function(length) {
17
+ this.data = new Uint8Array(length);
18
+ this.pos = 0;
19
+ };
20
+
21
+ ArrayBufferDataStream.prototype.seek = function(offset) {
22
+ this.pos = offset;
23
+ };
24
+
25
+ ArrayBufferDataStream.prototype.writeBytes = function(arr) {
26
+ for (var i = 0; i < arr.length; i++) {
27
+ this.data[this.pos++] = arr[i];
28
+ }
29
+ };
30
+
31
+ ArrayBufferDataStream.prototype.writeByte = function(b) {
32
+ this.data[this.pos++] = b;
33
+ };
34
+
35
+ //Synonym:
36
+ ArrayBufferDataStream.prototype.writeU8 = ArrayBufferDataStream.prototype.writeByte;
37
+
38
+ ArrayBufferDataStream.prototype.writeU16BE = function(u) {
39
+ this.data[this.pos++] = u >> 8;
40
+ this.data[this.pos++] = u;
41
+ };
42
+
43
+ ArrayBufferDataStream.prototype.writeDoubleBE = function(d) {
44
+ var
45
+ bytes = new Uint8Array(new Float64Array([d]).buffer);
46
+
47
+ for (var i = bytes.length - 1; i >= 0; i--) {
48
+ this.writeByte(bytes[i]);
49
+ }
50
+ };
51
+
52
+ ArrayBufferDataStream.prototype.writeFloatBE = function(d) {
53
+ var
54
+ bytes = new Uint8Array(new Float32Array([d]).buffer);
55
+
56
+ for (var i = bytes.length - 1; i >= 0; i--) {
57
+ this.writeByte(bytes[i]);
58
+ }
59
+ };
60
+
61
+ /**
62
+ * Write an ASCII string to the stream
63
+ */
64
+ ArrayBufferDataStream.prototype.writeString = function(s) {
65
+ for (var i = 0; i < s.length; i++) {
66
+ this.data[this.pos++] = s.charCodeAt(i);
67
+ }
68
+ };
69
+
70
+ /**
71
+ * Write the given 32-bit integer to the stream as an EBML variable-length integer using the given byte width
72
+ * (use measureEBMLVarInt).
73
+ *
74
+ * No error checking is performed to ensure that the supplied width is correct for the integer.
75
+ *
76
+ * @param i Integer to be written
77
+ * @param width Number of bytes to write to the stream
78
+ */
79
+ ArrayBufferDataStream.prototype.writeEBMLVarIntWidth = function(i, width) {
80
+ switch (width) {
81
+ case 1:
82
+ this.writeU8((1 << 7) | i);
83
+ break;
84
+ case 2:
85
+ this.writeU8((1 << 6) | (i >> 8));
86
+ this.writeU8(i);
87
+ break;
88
+ case 3:
89
+ this.writeU8((1 << 5) | (i >> 16));
90
+ this.writeU8(i >> 8);
91
+ this.writeU8(i);
92
+ break;
93
+ case 4:
94
+ this.writeU8((1 << 4) | (i >> 24));
95
+ this.writeU8(i >> 16);
96
+ this.writeU8(i >> 8);
97
+ this.writeU8(i);
98
+ break;
99
+ case 5:
100
+ /*
101
+ * JavaScript converts its doubles to 32-bit integers for bitwise operations, so we need to do a
102
+ * division by 2^32 instead of a right-shift of 32 to retain those top 3 bits
103
+ */
104
+ this.writeU8((1 << 3) | ((i / 4294967296) & 0x7));
105
+ this.writeU8(i >> 24);
106
+ this.writeU8(i >> 16);
107
+ this.writeU8(i >> 8);
108
+ this.writeU8(i);
109
+ break;
110
+ default:
111
+ throw new RuntimeException("Bad EBML VINT size " + width);
112
+ }
113
+ };
114
+
115
+ /**
116
+ * Return the number of bytes needed to encode the given integer as an EBML VINT.
117
+ */
118
+ ArrayBufferDataStream.prototype.measureEBMLVarInt = function(val) {
119
+ if (val < (1 << 7) - 1) {
120
+ /* Top bit is set, leaving 7 bits to hold the integer, but we can't store 127 because
121
+ * "all bits set to one" is a reserved value. Same thing for the other cases below:
122
+ */
123
+ return 1;
124
+ } else if (val < (1 << 14) - 1) {
125
+ return 2;
126
+ } else if (val < (1 << 21) - 1) {
127
+ return 3;
128
+ } else if (val < (1 << 28) - 1) {
129
+ return 4;
130
+ } else if (val < 34359738367) { // 2 ^ 35 - 1 (can address 32GB)
131
+ return 5;
132
+ } else {
133
+ throw new RuntimeException("EBML VINT size not supported " + val);
134
+ }
135
+ };
136
+
137
+ ArrayBufferDataStream.prototype.writeEBMLVarInt = function(i) {
138
+ this.writeEBMLVarIntWidth(i, this.measureEBMLVarInt(i));
139
+ };
140
+
141
+ /**
142
+ * Write the given unsigned 32-bit integer to the stream in big-endian order using the given byte width.
143
+ * No error checking is performed to ensure that the supplied width is correct for the integer.
144
+ *
145
+ * Omit the width parameter to have it determined automatically for you.
146
+ *
147
+ * @param u Unsigned integer to be written
148
+ * @param width Number of bytes to write to the stream
149
+ */
150
+ ArrayBufferDataStream.prototype.writeUnsignedIntBE = function(u, width) {
151
+ if (width === undefined) {
152
+ width = this.measureUnsignedInt(u);
153
+ }
154
+
155
+ // Each case falls through:
156
+ switch (width) {
157
+ case 5:
158
+ this.writeU8(Math.floor(u / 4294967296)); // Need to use division to access >32 bits of floating point var
159
+ case 4:
160
+ this.writeU8(u >> 24);
161
+ case 3:
162
+ this.writeU8(u >> 16);
163
+ case 2:
164
+ this.writeU8(u >> 8);
165
+ case 1:
166
+ this.writeU8(u);
167
+ break;
168
+ default:
169
+ throw new RuntimeException("Bad UINT size " + width);
170
+ }
171
+ };
172
+
173
+ /**
174
+ * Return the number of bytes needed to hold the non-zero bits of the given unsigned integer.
175
+ */
176
+ ArrayBufferDataStream.prototype.measureUnsignedInt = function(val) {
177
+ // Force to 32-bit unsigned integer
178
+ if (val < (1 << 8)) {
179
+ return 1;
180
+ } else if (val < (1 << 16)) {
181
+ return 2;
182
+ } else if (val < (1 << 24)) {
183
+ return 3;
184
+ } else if (val < 4294967296) {
185
+ return 4;
186
+ } else {
187
+ return 5;
188
+ }
189
+ };
190
+
191
+ /**
192
+ * Return a view on the portion of the buffer from the beginning to the current seek position as a Uint8Array.
193
+ */
194
+ ArrayBufferDataStream.prototype.getAsDataArray = function() {
195
+ if (this.pos < this.data.byteLength) {
196
+ return this.data.subarray(0, this.pos);
197
+ } else if (this.pos == this.data.byteLength) {
198
+ return this.data;
199
+ } else {
200
+ throw "ArrayBufferDataStream's pos lies beyond end of buffer";
201
+ }
202
+ };
203
+
204
+ if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
205
+ module.exports = ArrayBufferDataStream;
206
+ } else {
207
+ window.ArrayBufferDataStream = ArrayBufferDataStream;
208
+ }
209
+}());"use strict";
210
+
211
+/**
212
+ * Allows a series of Blob-convertible objects (ArrayBuffer, Blob, String, etc) to be added to a buffer. Seeking and
213
+ * overwriting of blobs is allowed.
214
+ *
215
+ * You can supply a FileWriter, in which case the BlobBuffer is just used as temporary storage before it writes it
216
+ * through to the disk.
217
+ *
218
+ * By Nicholas Sherlock
219
+ *
220
+ * Released under the WTFPLv2 https://en.wikipedia.org/wiki/WTFPL
221
+ */
222
+(function() {
223
+ var BlobBuffer = function(fs) {
224
+ return function(destination) {
225
+ var
226
+ buffer = [],
227
+ writePromise = Promise.resolve(),
228
+ fileWriter = null,
229
+ fd = null;
230
+
231
+ if (typeof FileWriter !== "undefined" && destination instanceof FileWriter) {
232
+ fileWriter = destination;
233
+ } else if (fs && destination) {
234
+ fd = destination;
235
+ }
236
+
237
+ // Current seek offset
238
+ this.pos = 0;
239
+
240
+ // One more than the index of the highest byte ever written
241
+ this.length = 0;
242
+
243
+ // Returns a promise that converts the blob to an ArrayBuffer
244
+ function readBlobAsBuffer(blob) {
245
+ return new Promise(function (resolve, reject) {
246
+ var
247
+ reader = new FileReader();
248
+
249
+ reader.addEventListener("loadend", function () {
250
+ resolve(reader.result);
251
+ });
252
+
253
+ reader.readAsArrayBuffer(blob);
254
+ });
255
+ }
256
+
257
+ function convertToUint8Array(thing) {
258
+ return new Promise(function (resolve, reject) {
259
+ if (thing instanceof Uint8Array) {
260
+ resolve(thing);
261
+ } else if (thing instanceof ArrayBuffer || ArrayBuffer.isView(thing)) {
262
+ resolve(new Uint8Array(thing));
263
+ } else if (thing instanceof Blob) {
264
+ resolve(readBlobAsBuffer(thing).then(function (buffer) {
265
+ return new Uint8Array(buffer);
266
+ }));
267
+ } else {
268
+ //Assume that Blob will know how to read this thing
269
+ resolve(readBlobAsBuffer(new Blob([thing])).then(function (buffer) {
270
+ return new Uint8Array(buffer);
271
+ }));
272
+ }
273
+ });
274
+ }
275
+
276
+ function measureData(data) {
277
+ var
278
+ result = data.byteLength || data.length || data.size;
279
+
280
+ if (!Number.isInteger(result)) {
281
+ throw "Failed to determine size of element";
282
+ }
283
+
284
+ return result;
285
+ }
286
+
287
+ /**
288
+ * Seek to the given absolute offset.
289
+ *
290
+ * You may not seek beyond the end of the file (this would create a hole and/or allow blocks to be written in non-
291
+ * sequential order, which isn't currently supported by the memory buffer backend).
292
+ */
293
+ this.seek = function (offset) {
294
+ if (offset < 0) {
295
+ throw "Offset may not be negative";
296
+ }
297
+
298
+ if (isNaN(offset)) {
299
+ throw "Offset may not be NaN";
300
+ }
301
+
302
+ if (offset > this.length) {
303
+ throw "Seeking beyond the end of file is not allowed";
304
+ }
305
+
306
+ this.pos = offset;
307
+ };
308
+
309
+ /**
310
+ * Write the Blob-convertible data to the buffer at the current seek position.
311
+ *
312
+ * Note: If overwriting existing data, the write must not cross preexisting block boundaries (written data must
313
+ * be fully contained by the extent of a previous write).
314
+ */
315
+ this.write = function (data) {
316
+ var
317
+ newEntry = {
318
+ offset: this.pos,
319
+ data: data,
320
+ length: measureData(data)
321
+ },
322
+ isAppend = newEntry.offset >= this.length;
323
+
324
+ this.pos += newEntry.length;
325
+ this.length = Math.max(this.length, this.pos);
326
+
327
+ // After previous writes complete, perform our write
328
+ writePromise = writePromise.then(function () {
329
+ if (fd) {
330
+ return new Promise(function(resolve, reject) {
331
+ convertToUint8Array(newEntry.data).then(function(dataArray) {
332
+ var
333
+ totalWritten = 0,
334
+ buffer = Buffer.from(dataArray.buffer),
335
+
336
+ handleWriteComplete = function(err, written, buffer) {
337
+ totalWritten += written;
338
+
339
+ if (totalWritten >= buffer.length) {
340
+ resolve();
341
+ } else {
342
+ // We still have more to write...
343
+ fs.write(fd, buffer, totalWritten, buffer.length - totalWritten, newEntry.offset + totalWritten, handleWriteComplete);
344
+ }
345
+ };
346
+
347
+ fs.write(fd, buffer, 0, buffer.length, newEntry.offset, handleWriteComplete);
348
+ });
349
+ });
350
+ } else if (fileWriter) {
351
+ return new Promise(function (resolve, reject) {
352
+ fileWriter.onwriteend = resolve;
353
+
354
+ fileWriter.seek(newEntry.offset);
355
+ fileWriter.write(new Blob([newEntry.data]));
356
+ });
357
+ } else if (!isAppend) {
358
+ // We might be modifying a write that was already buffered in memory.
359
+
360
+ // Slow linear search to find a block we might be overwriting
361
+ for (var i = 0; i < buffer.length; i++) {
362
+ var
363
+ entry = buffer[i];
364
+
365
+ // If our new entry overlaps the old one in any way...
366
+ if (!(newEntry.offset + newEntry.length <= entry.offset || newEntry.offset >= entry.offset + entry.length)) {
367
+ if (newEntry.offset < entry.offset || newEntry.offset + newEntry.length > entry.offset + entry.length) {
368
+ throw new Error("Overwrite crosses blob boundaries");
369
+ }
370
+
371
+ if (newEntry.offset == entry.offset && newEntry.length == entry.length) {
372
+ // We overwrote the entire block
373
+ entry.data = newEntry.data;
374
+
375
+ // We're done
376
+ return;
377
+ } else {
378
+ return convertToUint8Array(entry.data)
379
+ .then(function (entryArray) {
380
+ entry.data = entryArray;
381
+
382
+ return convertToUint8Array(newEntry.data);
383
+ }).then(function (newEntryArray) {
384
+ newEntry.data = newEntryArray;
385
+
386
+ entry.data.set(newEntry.data, newEntry.offset - entry.offset);
387
+ });
388
+ }
389
+ }
390
+ }
391
+ // Else fall through to do a simple append, as we didn't overwrite any pre-existing blocks
392
+ }
393
+
394
+ buffer.push(newEntry);
395
+ });
396
+ };
397
+
398
+ /**
399
+ * Finish all writes to the buffer, returning a promise that signals when that is complete.
400
+ *
401
+ * If a FileWriter was not provided, the promise is resolved with a Blob that represents the completed BlobBuffer
402
+ * contents. You can optionally pass in a mimeType to be used for this blob.
403
+ *
404
+ * If a FileWriter was provided, the promise is resolved with null as the first argument.
405
+ */
406
+ this.complete = function (mimeType) {
407
+ if (fd || fileWriter) {
408
+ writePromise = writePromise.then(function () {
409
+ return null;
410
+ });
411
+ } else {
412
+ // After writes complete we need to merge the buffer to give to the caller
413
+ writePromise = writePromise.then(function () {
414
+ var
415
+ result = [];
416
+
417
+ for (var i = 0; i < buffer.length; i++) {
418
+ result.push(buffer[i].data);
419
+ }
420
+
421
+ return new Blob(result, {mimeType: mimeType});
422
+ });
423
+ }
424
+
425
+ return writePromise;
426
+ };
427
+ };
428
+ };
429
+
430
+ if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
431
+ module.exports = BlobBuffer(require('fs'));
432
+ } else {
433
+ window.BlobBuffer = BlobBuffer(null);
434
+ }
435
+})();/**
436
+ * WebM video encoder for Google Chrome. This implementation is suitable for creating very large video files, because
437
+ * it can stream Blobs directly to a FileWriter without buffering the entire video in memory.
438
+ *
439
+ * When FileWriter is not available or not desired, it can buffer the video in memory as a series of Blobs which are
440
+ * eventually returned as one composite Blob.
441
+ *
442
+ * By Nicholas Sherlock.
443
+ *
444
+ * Based on the ideas from Whammy: https://github.com/antimatter15/whammy
445
+ *
446
+ * Released under the WTFPLv2 https://en.wikipedia.org/wiki/WTFPL
447
+ */
448
+
449
+"use strict";
450
+
451
+(function() {
452
+ var WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
453
+ function extend(base, top) {
454
+ var
455
+ target = {};
456
+
457
+ [base, top].forEach(function(obj) {
458
+ for (var prop in obj) {
459
+ if (Object.prototype.hasOwnProperty.call(obj, prop)) {
460
+ target[prop] = obj[prop];
461
+ }
462
+ }
463
+ });
464
+
465
+ return target;
466
+ }
467
+
468
+ /**
469
+ * Decode a Base64 data URL into a binary string.
470
+ *
471
+ * Returns the binary string, or false if the URL could not be decoded.
472
+ */
473
+ function decodeBase64WebPDataURL(url) {
474
+ if (typeof url !== "string" || !url.match(/^data:image\/webp;base64,/i)) {
475
+ return false;
476
+ }
477
+
478
+ return window.atob(url.substring("data:image\/webp;base64,".length));
479
+ }
480
+
481
+ /**
482
+ * Convert a raw binary string (one character = one output byte) to an ArrayBuffer
483
+ */
484
+ function stringToArrayBuffer(string) {
485
+ var
486
+ buffer = new ArrayBuffer(string.length),
487
+ int8Array = new Uint8Array(buffer);
488
+
489
+ for (var i = 0; i < string.length; i++) {
490
+ int8Array[i] = string.charCodeAt(i);
491
+ }
492
+
493
+ return buffer;
494
+ }
495
+
496
+ /**
497
+ * Convert the given canvas to a WebP encoded image and return the image data as a string.
498
+ */
499
+ function renderAsWebP(canvas, quality) {
500
+ var
501
+ frame = canvas.toDataURL('image/webp', quality);
502
+
503
+ return decodeBase64WebPDataURL(frame);
504
+ }
505
+
506
+ function extractKeyframeFromWebP(webP) {
507
+ // Assume that Chrome will generate a Simple Lossy WebP which has this header:
508
+ var
509
+ keyframeStartIndex = webP.indexOf('VP8 ');
510
+
511
+ if (keyframeStartIndex == -1) {
512
+ throw "Failed to identify beginning of keyframe in WebP image";
513
+ }
514
+
515
+ // Skip the header and the 4 bytes that encode the length of the VP8 chunk
516
+ keyframeStartIndex += 'VP8 '.length + 4;
517
+
518
+ return webP.substring(keyframeStartIndex);
519
+ }
520
+
521
+ // Just a little utility so we can tag values as floats for the EBML encoder's benefit
522
+ function EBMLFloat32(value) {
523
+ this.value = value;
524
+ }
525
+
526
+ function EBMLFloat64(value) {
527
+ this.value = value;
528
+ }
529
+
530
+ /**
531
+ * Write the given EBML object to the provided ArrayBufferStream.
532
+ *
533
+ * The buffer's first byte is at bufferFileOffset inside the video file. This is used to complete offset and
534
+ * dataOffset fields in each EBML structure, indicating the file offset of the first byte of the EBML element and
535
+ * its data payload.
536
+ */
537
+ function writeEBML(buffer, bufferFileOffset, ebml) {
538
+ // Is the ebml an array of sibling elements?
539
+ if (Array.isArray(ebml)) {
540
+ for (var i = 0; i < ebml.length; i++) {
541
+ writeEBML(buffer, bufferFileOffset, ebml[i]);
542
+ }
543
+ // Is this some sort of raw data that we want to write directly?
544
+ } else if (typeof ebml === "string") {
545
+ buffer.writeString(ebml);
546
+ } else if (ebml instanceof Uint8Array) {
547
+ buffer.writeBytes(ebml);
548
+ } else if (ebml.id){
549
+ // We're writing an EBML element
550
+ ebml.offset = buffer.pos + bufferFileOffset;
551
+
552
+ buffer.writeUnsignedIntBE(ebml.id); // ID field
553
+
554
+ // Now we need to write the size field, so we must know the payload size:
555
+
556
+ if (Array.isArray(ebml.data)) {
557
+ // Writing an array of child elements. We won't try to measure the size of the children up-front
558
+
559
+ var
560
+ sizePos, dataBegin, dataEnd;
561
+
562
+ if (ebml.size === -1) {
563
+ // Write the reserved all-one-bits marker to note that the size of this element is unknown/unbounded
564
+ buffer.writeByte(0xFF);
565
+ } else {
566
+ sizePos = buffer.pos;
567
+
568
+ /* Write a dummy size field to overwrite later. 4 bytes allows an element maximum size of 256MB,
569
+ * which should be plenty (we don't want to have to buffer that much data in memory at one time
570
+ * anyway!)
571
+ */
572
+ buffer.writeBytes([0, 0, 0, 0]);
573
+ }
574
+
575
+ dataBegin = buffer.pos;
576
+
577
+ ebml.dataOffset = dataBegin + bufferFileOffset;
578
+ writeEBML(buffer, bufferFileOffset, ebml.data);
579
+
580
+ if (ebml.size !== -1) {
581
+ dataEnd = buffer.pos;
582
+
583
+ ebml.size = dataEnd - dataBegin;
584
+
585
+ buffer.seek(sizePos);
586
+ buffer.writeEBMLVarIntWidth(ebml.size, 4); // Size field
587
+
588
+ buffer.seek(dataEnd);
589
+ }
590
+ } else if (typeof ebml.data === "string") {
591
+ buffer.writeEBMLVarInt(ebml.data.length); // Size field
592
+ ebml.dataOffset = buffer.pos + bufferFileOffset;
593
+ buffer.writeString(ebml.data);
594
+ } else if (typeof ebml.data === "number") {
595
+ // Allow the caller to explicitly choose the size if they wish by supplying a size field
596
+ if (!ebml.size) {
597
+ ebml.size = buffer.measureUnsignedInt(ebml.data);
598
+ }
599
+
600
+ buffer.writeEBMLVarInt(ebml.size); // Size field
601
+ ebml.dataOffset = buffer.pos + bufferFileOffset;
602
+ buffer.writeUnsignedIntBE(ebml.data, ebml.size);
603
+ } else if (ebml.data instanceof EBMLFloat64) {
604
+ buffer.writeEBMLVarInt(8); // Size field
605
+ ebml.dataOffset = buffer.pos + bufferFileOffset;
606
+ buffer.writeDoubleBE(ebml.data.value);
607
+ } else if (ebml.data instanceof EBMLFloat32) {
608
+ buffer.writeEBMLVarInt(4); // Size field
609
+ ebml.dataOffset = buffer.pos + bufferFileOffset;
610
+ buffer.writeFloatBE(ebml.data.value);
611
+ } else if (ebml.data instanceof Uint8Array) {
612
+ buffer.writeEBMLVarInt(ebml.data.byteLength); // Size field
613
+ ebml.dataOffset = buffer.pos + bufferFileOffset;
614
+ buffer.writeBytes(ebml.data);
615
+ } else {
616
+ throw "Bad EBML datatype " + typeof ebml.data;
617
+ }
618
+ } else {
619
+ throw "Bad EBML datatype " + typeof ebml.data;
620
+ }
621
+ }
622
+
623
+ return function(options) {
624
+ var
625
+ MAX_CLUSTER_DURATION_MSEC = 5000,
626
+ DEFAULT_TRACK_NUMBER = 1,
627
+
628
+ writtenHeader = false,
629
+ videoWidth, videoHeight,
630
+
631
+ clusterFrameBuffer = [],
632
+ clusterStartTime = 0,
633
+ clusterDuration = 0,
634
+
635
+ optionDefaults = {
636
+ quality: 0.95, // WebM image quality from 0.0 (worst) to 1.0 (best)
637
+ fileWriter: null, // Chrome FileWriter in order to stream to a file instead of buffering to memory (optional)
638
+ fd: null, // Node.JS file descriptor to write to instead of buffering (optional)
639
+
640
+ // You must supply one of:
641
+ frameDuration: null, // Duration of frames in milliseconds
642
+ frameRate: null, // Number of frames per second
643
+ },
644
+
645
+ seekPoints = {
646
+ Cues: {id: new Uint8Array([0x1C, 0x53, 0xBB, 0x6B]), positionEBML: null},
647
+ SegmentInfo: {id: new Uint8Array([0x15, 0x49, 0xA9, 0x66]), positionEBML: null},
648
+ Tracks: {id: new Uint8Array([0x16, 0x54, 0xAE, 0x6B]), positionEBML: null},
649
+ },
650
+
651
+ ebmlSegment,
652
+ segmentDuration = {
653
+ "id": 0x4489, // Duration
654
+ "data": new EBMLFloat64(0)
655
+ },
656
+
657
+ seekHead,
658
+
659
+ cues = [],
660
+
661
+ blobBuffer = new BlobBuffer(options.fileWriter || options.fd);
662
+
663
+ function fileOffsetToSegmentRelative(fileOffset) {
664
+ return fileOffset - ebmlSegment.dataOffset;
665
+ }
666
+
667
+ /**
668
+ * Create a SeekHead element with descriptors for the points in the global seekPoints array.
669
+ *
670
+ * 5 bytes of position values are reserved for each node, which lie at the offset point.positionEBML.dataOffset,
671
+ * to be overwritten later.
672
+ */
673
+ function createSeekHead() {
674
+ var
675
+ seekPositionEBMLTemplate = {
676
+ "id": 0x53AC, // SeekPosition
677
+ "size": 5, // Allows for 32GB video files
678
+ "data": 0 // We'll overwrite this when the file is complete
679
+ },
680
+
681
+ result = {
682
+ "id": 0x114D9B74, // SeekHead
683
+ "data": []
684
+ };
685
+
686
+ for (var name in seekPoints) {
687
+ var
688
+ seekPoint = seekPoints[name];
689
+
690
+ seekPoint.positionEBML = Object.create(seekPositionEBMLTemplate);
691
+
692
+ result.data.push({
693
+ "id": 0x4DBB, // Seek
694
+ "data": [
695
+ {
696
+ "id": 0x53AB, // SeekID
697
+ "data": seekPoint.id
698
+ },
699
+ seekPoint.positionEBML
700
+ ]
701
+ });
702
+ }
703
+
704
+ return result;
705
+ }
706
+
707
+ /**
708
+ * Write the WebM file header to the stream.
709
+ */
710
+ function writeHeader() {
711
+ seekHead = createSeekHead();
712
+
713
+ var
714
+ ebmlHeader = {
715
+ "id": 0x1a45dfa3, // EBML
716
+ "data": [
717
+ {
718
+ "id": 0x4286, // EBMLVersion
719
+ "data": 1
720
+ },
721
+ {
722
+ "id": 0x42f7, // EBMLReadVersion
723
+ "data": 1
724
+ },
725
+ {
726
+ "id": 0x42f2, // EBMLMaxIDLength
727
+ "data": 4
728
+ },
729
+ {
730
+ "id": 0x42f3, // EBMLMaxSizeLength
731
+ "data": 8
732
+ },
733
+ {
734
+ "id": 0x4282, // DocType
735
+ "data": "webm"
736
+ },
737
+ {
738
+ "id": 0x4287, // DocTypeVersion
739
+ "data": 2
740
+ },
741
+ {
742
+ "id": 0x4285, // DocTypeReadVersion
743
+ "data": 2
744
+ }
745
+ ]
746
+ },
747
+
748
+ segmentInfo = {
749
+ "id": 0x1549a966, // Info
750
+ "data": [
751
+ {
752
+ "id": 0x2ad7b1, // TimecodeScale
753
+ "data": 1e6 // Times will be in miliseconds (1e6 nanoseconds per step = 1ms)
754
+ },
755
+ {
756
+ "id": 0x4d80, // MuxingApp
757
+ "data": "webm-writer-js",
758
+ },
759
+ {
760
+ "id": 0x5741, // WritingApp
761
+ "data": "webm-writer-js"
762
+ },
763
+ segmentDuration // To be filled in later
764
+ ]
765
+ },
766
+
767
+ tracks = {
768
+ "id": 0x1654ae6b, // Tracks
769
+ "data": [
770
+ {
771
+ "id": 0xae, // TrackEntry
772
+ "data": [
773
+ {
774
+ "id": 0xd7, // TrackNumber
775
+ "data": DEFAULT_TRACK_NUMBER
776
+ },
777
+ {
778
+ "id": 0x73c5, // TrackUID
779
+ "data": DEFAULT_TRACK_NUMBER
780
+ },
781
+ {
782
+ "id": 0x9c, // FlagLacing
783
+ "data": 0
784
+ },
785
+ {
786
+ "id": 0x22b59c, // Language
787
+ "data": "und"
788
+ },
789
+ {
790
+ "id": 0x86, // CodecID
791
+ "data": "V_VP8"
792
+ },
793
+ {
794
+ "id": 0x258688, // CodecName
795
+ "data": "VP8"
796
+ },
797
+ {
798
+ "id": 0x83, // TrackType
799
+ "data": 1
800
+ },
801
+ {
802
+ "id": 0xe0, // Video
803
+ "data": [
804
+ {
805
+ "id": 0xb0, // PixelWidth
806
+ "data": videoWidth
807
+ },
808
+ {
809
+ "id": 0xba, // PixelHeight
810
+ "data": videoHeight
811
+ }
812
+ ]
813
+ }
814
+ ]
815
+ }
816
+ ]
817
+ };
818
+
819
+ ebmlSegment = {
820
+ "id": 0x18538067, // Segment
821
+ "size": -1, // Unbounded size
822
+ "data": [
823
+ seekHead,
824
+ segmentInfo,
825
+ tracks,
826
+ ]
827
+ };
828
+
829
+ var
830
+ bufferStream = new ArrayBufferDataStream(256);
831
+
832
+ writeEBML(bufferStream, blobBuffer.pos, [ebmlHeader, ebmlSegment]);
833
+ blobBuffer.write(bufferStream.getAsDataArray());
834
+
835
+ // Now we know where these top-level elements lie in the file:
836
+ seekPoints.SegmentInfo.positionEBML.data = fileOffsetToSegmentRelative(segmentInfo.offset);
837
+ seekPoints.Tracks.positionEBML.data = fileOffsetToSegmentRelative(tracks.offset);
838
+ };
839
+
840
+ /**
841
+ * Create a SimpleBlock keyframe header using these fields:
842
+ * timecode - Time of this keyframe
843
+ * trackNumber - Track number from 1 to 126 (inclusive)
844
+ * frame - Raw frame data payload string
845
+ *
846
+ * Returns an EBML element.
847
+ */
848
+ function createKeyframeBlock(keyframe) {
849
+ var
850
+ bufferStream = new ArrayBufferDataStream(1 + 2 + 1);
851
+
852
+ if (!(keyframe.trackNumber > 0 && keyframe.trackNumber < 127)) {
853
+ throw "TrackNumber must be > 0 and < 127";
854
+ }
855
+
856
+ bufferStream.writeEBMLVarInt(keyframe.trackNumber); // Always 1 byte since we limit the range of trackNumber
857
+ bufferStream.writeU16BE(keyframe.timecode);
858
+
859
+ // Flags byte
860
+ bufferStream.writeByte(
861
+ 1 << 7 // Keyframe
862
+ );
863
+
864
+ return {
865
+ "id": 0xA3, // SimpleBlock
866
+ "data": [
867
+ bufferStream.getAsDataArray(),
868
+ keyframe.frame
869
+ ]
870
+ };
871
+ }
872
+
873
+ /**
874
+ * Create a Cluster node using these fields:
875
+ *
876
+ * timecode - Start time for the cluster
877
+ *
878
+ * Returns an EBML element.
879
+ */
880
+ function createCluster(cluster) {
881
+ return {
882
+ "id": 0x1f43b675,
883
+ "data": [
884
+ {
885
+ "id": 0xe7, // Timecode
886
+ "data": Math.round(cluster.timecode)
887
+ }
888
+ ]
889
+ };
890
+ }
891
+
892
+ function addCuePoint(trackIndex, clusterTime, clusterFileOffset) {
893
+ cues.push({
894
+ "id": 0xBB, // Cue
895
+ "data": [
896
+ {
897
+ "id": 0xB3, // CueTime
898
+ "data": clusterTime
899
+ },
900
+ {
901
+ "id": 0xB7, // CueTrackPositions
902
+ "data": [
903
+ {
904
+ "id": 0xF7, // CueTrack
905
+ "data": trackIndex
906
+ },
907
+ {
908
+ "id": 0xF1, // CueClusterPosition
909
+ "data": fileOffsetToSegmentRelative(clusterFileOffset)
910
+ }
911
+ ]
912
+ }
913
+ ]
914
+ });
915
+ }
916
+
917
+ /**
918
+ * Write a Cues element to the blobStream using the global `cues` array of CuePoints (use addCuePoint()).
919
+ * The seek entry for the Cues in the SeekHead is updated.
920
+ */
921
+ function writeCues() {
922
+ var
923
+ ebml = {
924
+ "id": 0x1C53BB6B,
925
+ "data": cues
926
+ },
927
+
928
+ cuesBuffer = new ArrayBufferDataStream(16 + cues.length * 32); // Pretty crude estimate of the buffer size we'll need
929
+
930
+ writeEBML(cuesBuffer, blobBuffer.pos, ebml);
931
+ blobBuffer.write(cuesBuffer.getAsDataArray());
932
+
933
+ // Now we know where the Cues element has ended up, we can update the SeekHead
934
+ seekPoints.Cues.positionEBML.data = fileOffsetToSegmentRelative(ebml.offset);
935
+ }
936
+
937
+ /**
938
+ * Flush the frames in the current clusterFrameBuffer out to the stream as a Cluster.
939
+ */
940
+ function flushClusterFrameBuffer() {
941
+ if (clusterFrameBuffer.length == 0) {
942
+ return;
943
+ }
944
+
945
+ // First work out how large of a buffer we need to hold the cluster data
946
+ var
947
+ rawImageSize = 0;
948
+
949
+ for (var i = 0; i < clusterFrameBuffer.length; i++) {
950
+ rawImageSize += clusterFrameBuffer[i].frame.length;
951
+ }
952
+
953
+ var
954
+ buffer = new ArrayBufferDataStream(rawImageSize + clusterFrameBuffer.length * 32), // Estimate 32 bytes per SimpleBlock header
955
+
956
+ cluster = createCluster({
957
+ timecode: Math.round(clusterStartTime),
958
+ });
959
+
960
+ for (var i = 0; i < clusterFrameBuffer.length; i++) {
961
+ cluster.data.push(createKeyframeBlock(clusterFrameBuffer[i]));
962
+ }
963
+
964
+ writeEBML(buffer, blobBuffer.pos, cluster);
965
+ blobBuffer.write(buffer.getAsDataArray());
966
+
967
+ addCuePoint(DEFAULT_TRACK_NUMBER, Math.round(clusterStartTime), cluster.offset);
968
+
969
+ clusterFrameBuffer = [];
970
+ clusterStartTime += clusterDuration;
971
+ clusterDuration = 0;
972
+ }
973
+
974
+ function validateOptions() {
975
+ // Derive frameDuration setting if not already supplied
976
+ if (!options.frameDuration) {
977
+ if (options.frameRate) {
978
+ options.frameDuration = 1000 / options.frameRate;
979
+ } else {
980
+ throw "Missing required frameDuration or frameRate setting";
981
+ }
982
+ }
983
+ }
984
+
985
+ function addFrameToCluster(frame) {
986
+ frame.trackNumber = DEFAULT_TRACK_NUMBER;
987
+
988
+ // Frame timecodes are relative to the start of their cluster:
989
+ frame.timecode = Math.round(clusterDuration);
990
+
991
+ clusterFrameBuffer.push(frame);
992
+
993
+ clusterDuration += frame.duration;
994
+
995
+ if (clusterDuration >= MAX_CLUSTER_DURATION_MSEC) {
996
+ flushClusterFrameBuffer();
997
+ }
998
+ }
999
+
1000
+ /**
1001
+ * Rewrites the SeekHead element that was initially written to the stream with the offsets of top level elements.
1002
+ *
1003
+ * Call once writing is complete (so the offset of all top level elements is known).
1004
+ */
1005
+ function rewriteSeekHead() {
1006
+ var
1007
+ seekHeadBuffer = new ArrayBufferDataStream(seekHead.size),
1008
+ oldPos = blobBuffer.pos;
1009
+
1010
+ // Write the rewritten SeekHead element's data payload to the stream (don't need to update the id or size)
1011
+ writeEBML(seekHeadBuffer, seekHead.dataOffset, seekHead.data);
1012
+
1013
+ // And write that through to the file
1014
+ blobBuffer.seek(seekHead.dataOffset);
1015
+ blobBuffer.write(seekHeadBuffer.getAsDataArray());
1016
+
1017
+ blobBuffer.seek(oldPos);
1018
+ }
1019
+
1020
+ /**
1021
+ * Rewrite the Duration field of the Segment with the newly-discovered video duration.
1022
+ */
1023
+ function rewriteDuration() {
1024
+ var
1025
+ buffer = new ArrayBufferDataStream(8),
1026
+ oldPos = blobBuffer.pos;
1027
+
1028
+ // Rewrite the data payload (don't need to update the id or size)
1029
+ buffer.writeDoubleBE(clusterStartTime);
1030
+
1031
+ // And write that through to the file
1032
+ blobBuffer.seek(segmentDuration.dataOffset);
1033
+ blobBuffer.write(buffer.getAsDataArray());
1034
+
1035
+ blobBuffer.seek(oldPos);
1036
+ }
1037
+
1038
+ /**
1039
+ * Add a frame to the video. Currently the frame must be a Canvas element.
1040
+ */
1041
+ this.addFrame = function(canvas, duration) {
1042
+ //if (writtenHeader) {
1043
+ // if (canvas.width != videoWidth || canvas.height != videoHeight) {
1044
+ // throw "Frame size differs from previous frames";
1045
+ // }
1046
+ //} else {
1047
+ videoWidth = canvas.width;
1048
+ videoHeight = canvas.height;
1049
+
1050
+ writeHeader();
1051
+ writtenHeader = true;
1052
+ //}
1053
+
1054
+ var
1055
+ webP = renderAsWebP(canvas, options.quality);
1056
+
1057
+ if (!webP) {
1058
+ throw "Couldn't decode WebP frame, does the browser support WebP?";
1059
+ }
1060
+
1061
+ addFrameToCluster({
1062
+ frame: extractKeyframeFromWebP(webP),
1063
+ duration: ((typeof duration == 'number')?duration:options.frameDuration)
1064
+ });
1065
+ };
1066
+
1067
+ /**
1068
+ * Finish writing the video and return a Promise to signal completion.
1069
+ *
1070
+ * If the destination device was memory (i.e. options.fileWriter was not supplied), the Promise is resolved with
1071
+ * a Blob with the contents of the entire video.
1072
+ */
1073
+ this.complete = function() {
1074
+ flushClusterFrameBuffer();
1075
+
1076
+ writeCues();
1077
+ rewriteSeekHead();
1078
+ rewriteDuration();
1079
+
1080
+ return blobBuffer.complete('video/webm');
1081
+ };
1082
+
1083
+ this.getWrittenSize = function() {
1084
+ return blobBuffer.length;
1085
+ };
1086
+
1087
+ options = extend(optionDefaults, options || {});
1088
+ validateOptions();
1089
+ };
1090
+ };
1091
+
1092
+ if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
1093
+ module.exports = WebMWriter(require("./ArrayBufferDataStream"), require("./BlobBuffer"));
1094
+ } else {
1095
+ window.WebMWriter = WebMWriter(ArrayBufferDataStream, BlobBuffer);
1096
+ }
1097
+})();
\ No newline at end of file
views/player.handlebars
+70
-2
@@ -17,13 +17,16 @@
17
<script type="text/javascript" src="scripts/zlib-inflate{{min}}.js"></script>
18
<script type="text/javascript" src="scripts/zlib-adler32{{min}}.js"></script>
19
<script type="text/javascript" src="scripts/zlib-crc32{{min}}.js"></script>
20
+ <script keeplink=1 type="text/javascript" src="scripts/webm-writer.js"></script>
21
+ <script keeplink=1 type="text/javascript" src="scripts/filesaver.min.js"></script>
22
</head>
23
<body style="overflow:hidden;background-color:black">
24
<div id=p11 class="noselect" style="overflow:hidden">
25
<div id=deskarea0>
26
<div id=deskarea1 class="areaHead">
27
<div class="toright2">
26
- <div class='deskareaicon' title="Toggle View Mode" onclick="toggleAspectRatio(1)">⇲</div>
28
+ <div class='deskareaicon' title="Toggle View Mode" onclick="toggleAspectRatio(1)">⇲</div>
29
+ <input id="ConvertAsWebM" style="display:none" type=button value="Convert to WebM" onclick="saveAsWebMfile()">
30
</div>
31
<div>
32
<input id="OpenFileButton" type=button value="Open File..." onclick="openfile()">
@@ -105,8 +108,26 @@
108
var waitTimerArgs = null;
109
var deskAspectRatio = 0;
110
var currentDeltaTimeTotalSec = 0;
111
+ var videoWriter = null;
112
+ var videoWriterLastFrame = null;
113
+ var videoWriterCurrentFrame = null;
114
+ var browser = null;
115
116
function start() {
117
+ // Detect what browser is in use
118
+ browser = (function (agent) {
119
+ switch (true) {
120
+ case agent.indexOf("edge") > -1: return "MS Edge (EdgeHtml)";
121
+ case agent.indexOf("edg") > -1: return "MS Edge Chromium";
122
+ case agent.indexOf("opr") > -1 && !!window.opr: return "opera";
123
+ case agent.indexOf("chrome") > -1 && !!window.chrome: return "chrome";
124
+ case agent.indexOf("trident") > -1: return "Internet Explorer";
125
+ case agent.indexOf("firefox") > -1: return "firefox";
126
+ case agent.indexOf("safari") > -1: return "safari";
127
+ default: return "other";
128
+ }
129
+ })(window.navigator.userAgent.toLowerCase());
130
+
131
window.onresize = deskAdjust;
132
document.ondrop = ondrop;
133
document.ondragover = ondragover;
@@ -237,6 +258,7 @@
258
}
259
QV('DeskParent', true);
260
QV('TermParent', false);
261
+ QV('ConvertAsWebM', false);
262
if (recFileMetadata.protocol == 1) {
263
// MeshCentral remote terminal
264
recFileProtocol = 1;
@@ -256,8 +278,10 @@
278
recFileStartTime = recFileLastTime = time;
279
agentDesktop = CreateAgentRemoteDesktop('Desk');
280
agentDesktop.onScreenSizeChange = deskAdjust;
281
+ agentDesktop.onPreDrawImage = preCanvasDraw;
282
agentDesktop.State = 3;
283
deskAdjust();
284
+ QV('ConvertAsWebM', browser == 'chrome'); // Only show the "Convert to WebM button when in Chrome
285
}
286
else if (recFileMetadata.protocol == 101) {
287
// Intel AMT Redirection
@@ -269,6 +293,7 @@
293
recFileStartTime = recFileLastTime = time;
294
amtDesktop = CreateAmtRemoteDesktop('Desk');
295
amtDesktop.onScreenSizeChange = deskAdjust;
296
+ amtDesktop.onPreDrawImage = preCanvasDraw;
297
if (recFileMetadata.bpp) { amtDesktop.bpp = recFileMetadata.bpp; }
298
amtDesktop.State = 3;
299
amtDesktop.Start();
@@ -284,11 +309,13 @@
309
recFileStartTime = recFileLastTime = time;
310
amtDesktop = CreateAmtRemoteDesktop('Desk');
311
amtDesktop.onScreenSizeChange = deskAdjust;
312
+ amtDesktop.onPreDrawImage = preCanvasDraw;
313
amtDesktop.State = 3;
314
amtDesktop.Start();
315
if (recFileMetadata.bpp) { amtDesktop.bpp = recFileMetadata.bpp; }
316
amtDesktop.state = 3;
317
deskAdjust();
318
+ QV('ConvertAsWebM', browser == 'chrome'); // Only show the "Convert to WebM button when in Chrome
319
}
320
QV('metadatadiv', true);
321
QH('metadatadiv', x);
@@ -299,7 +326,7 @@
326
function processBlock(type, flags, time, data) {
327
if (type < 0) { pause(); return; }
328
var waitTime = Math.round((time - recFileLastTime) * parseFloat(Q('PlaySpeed').value));
302
- if (waitTime < 5) {
329
+ if ((waitTime < 5) || (videoWriter != null)) {
330
processBlockEx(type, flags, time, data);
331
} else {
332
waitTimerArgs = [type, flags, time, data]
@@ -311,6 +338,20 @@
338
if ((playing == false) && (forced !== true)) return;
339
var flagBinary = (flags & 1) != 0, flagUser = (flags & 2) != 0;
340
341
+ // End of the stream, close the WebM converter
342
+ if ((type == 3) && (videoWriter != null)) {
343
+ preCanvasDraw();
344
+ videoWriter.complete().then(function (webMBlob) {
345
+ saveAs(webMBlob, recFile.name.replace('.mcrec', '.webm'));
346
+ videoWriter = null;
347
+ QE('PlaySpeed', true);
348
+ QE('SeekBackwardButton', true);
349
+ QE('SeekForwardButton', true);
350
+ QE('ConvertAsWebM', true);
351
+ QE('OpenFileButton', true);
352
+ });
353
+ }
354
+
355
if (type == 2) {
356
// Update the clock
357
recFileLastTime = time;
@@ -323,6 +364,10 @@
364
var secs = Math.floor(deltaTimeTotalSec % 60);
365
QH('timespan', pad2(hrs) + ':' + pad2(mins) + ':' + pad2(secs))
366
}
367
+
368
+ // Set the initial time on the WebM movie writer if needed
369
+ if (videoWriterLastFrame == null) { videoWriterLastFrame = time; }
370
+ videoWriterCurrentFrame = time;
371
}
372
373
if ((type == 2) && flagBinary && !flagUser) {
@@ -463,6 +508,29 @@
508
if (e.key == '0') { pause(); restart(); haltEvent(e); }
509
}
510
511
+ // Convert the remote desktop or KVM file into a WebM movie file.
512
+ function saveAsWebMfile() {
513
+ videoWriterLastFrame = null;
514
+ videoWriter = new WebMWriter({ quality: 0.60, frameDuration: 10, transparent: false });
515
+ restart();
516
+ play();
517
+ QE('PlayButton', false);
518
+ QE('PauseButton', false);
519
+ QE('RestartButton', false);
520
+ QE('PlaySpeed', false);
521
+ QE('SeekBackwardButton', false);
522
+ QE('SeekForwardButton', false);
523
+ QE('ConvertAsWebM', false);
524
+ QE('OpenFileButton', false);
525
+ }
526
+
527
+ function preCanvasDraw() {
528
+ if (videoWriter) {
529
+ var delta = videoWriterCurrentFrame - videoWriterLastFrame;
530
+ if (delta >= 100) { videoWriter.addFrame(Q('Desk'), delta); videoWriterLastFrame = videoWriterCurrentFrame; }
531
+ }
532
+ }
533
+
534
function openfile() {
535
var x = '<input type=file name=files id=p2fileinput style=width:100% accept=".mcrec" onchange="openfileChanged()" />';
536
setDialogMode(2, "Open File...", 3, openfileEx, x);