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)">&#8690;</div>
28 + <div class='deskareaicon' title="Toggle View Mode" onclick="toggleAspectRatio(1)">&#8690;</div>&nbsp;
29 + <input id="ConvertAsWebM" style="display:none" type=button value="Convert to WebM" onclick="saveAsWebMfile()">&nbsp;
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);