-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathscript.js
2565 lines (2503 loc) · 157 KB
/
script.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// @ts-check
"use strict";//~ counts for the whole file
//~ _____ ___________ _ _
//~ | __ \_ _| ___| | | | |
//~ | | \/ | | | |_ __| | ___ ___ ___ __| | ___
//~ | | __ | | | _| / _` |/ _ \/ __/ _ \ / _` |/ _ \
//~ | |_\ \_| |_| | | (_| | __/ (_| (_) | (_| | __/
//~ \____/\___/\_| \__,_|\___|\___\___/ \__,_|\___|
//MARK: GIF decode
// TODO add support for GIF87a
// TODO ? test if corrupted files throw errors ~ like color index out of range...
// TODO ~ build single class with all features; for ease of use (make import-able from anywhere and replace GIFdecodeModule.js with new GIF.js and remove here)
// TODO ? maybe add overrideInterlaced "do not decode interlaced frames" ! overrides decoding not rendering
// TODO ? show interlace flags after decoding
/**@class@template {unknown} T*/
const Interrupt=class Interrupt{
/**
* @typedef {Object} InterruptSignal
* @property {(timeout?:number)=>Promise<boolean>} check - see docs within {@linkcode Interrupt}
* @property {AbortSignal} signal - see docs within {@linkcode Interrupt}
*/
static #InterruptSignal=class InterruptSignal{
#ref;
/**
* ## Create an {@linkcode AbortSignal} that will abort when {@linkcode Interrupt.abort} is called
* when aborted, {@linkcode AbortSignal.reason} will be a reference to `this` {@linkcode InterruptSignal} object\
* ! NOT influenced by {@linkcode Interrupt.pause}
*/
get signal(){return this.#ref.#controller.signal;}
/**
* ## Create a new interrupt signal
* can not modify signal, only read
* @param {Interrupt} ref - reference to corresponding {@linkcode Interrupt} object
* @throws {TypeError} when {@linkcode ref} is not an {@linkcode Interrupt} reference
*/
constructor(ref){
if(!(ref instanceof Interrupt))throw new TypeError("[InterruptSignal] ref is not an Interrupt reference.");
this.#ref=ref;
}
/**
* ## Check if signal was aborted
* return delayed until signal is unpaused
* @param {number} [timeout] - time in milliseconds for delay between pause-checks - default `0`
* @returns {Promise<boolean>} when signal is aborted `true` otherwise `false`
* @throws {TypeError} when {@linkcode timeout} is given but not a positive finite number
*/
async check(timeout){
if(timeout!=null&&(typeof timeout!=="number"||!Number.isFinite(timeout)||timeout<0))throw TypeError("[InterruptSignal] timeout is not a positive finite number.");
const delay=timeout??0;
for(;this.#ref.#paused;)await new Promise(E=>setTimeout(E,delay));
return this.#ref.#aborted;
}
static{//~ make class and prototype immutable
Object.freeze(InterruptSignal.prototype);
Object.freeze(InterruptSignal);
}
};
/**
* ## Check if {@linkcode obj} is an interrupt signal (instance)
* @param {unknown} obj
* @returns {boolean} gives `true` when it is an interrupt signal and `false` otherwise
*/
static isSignal(obj){return obj instanceof Interrupt.#InterruptSignal;}
#paused=false;
#aborted=false;
#controller=new AbortController();
#signal=new Interrupt.#InterruptSignal(this);
/**@type {T|undefined}*/
#reason=undefined;
/**## get a signal for (only) checking for an abort*/
get signal(){return this.#signal;}
/**## get the reason for abort (`undefined` before abort)*/
get reason(){return this.#reason;}
/**## Pause signal (when not aborted)*/
pause(){this.#paused=!this.#aborted;}
/**## Unpause signal*/
resume(){this.#paused=false;}
/**
* ## Abort signal
* also unpauses signal
* @param {T} [reason] - reason for abort
*/
abort(reason){
this.#reason=reason;
this.#aborted=true;
this.#paused=false;
this.#controller.abort(this.#signal);
}
static{//~ make class and prototype immutable
Object.freeze(Interrupt.prototype);
Object.freeze(Interrupt);
}
};
/**
* @typedef {Object} GIF
* @property {number} width the width of the image in pixels (logical screen size)
* @property {number} height the height of the image in pixels (logical screen size)
* @property {number} totalTime the (maximum) total duration of the gif in milliseconds (all delays added together) - will be `Infinity` if there is a frame with the user input delay flag set and no timeout
* @property {number} colorRes the color depth/resolution in bits per color (in the original) [1-8 bits]
* @property {number} pixelAspectRatio if non zero the pixel aspect ratio will be from 4:1 to 1:4 in 1/64th increments
* @property {boolean} sortFlag if the colors in the global color table are ordered after decreasing importance
* @property {[number,number,number][]} globalColorTable the global color table for the GIF
* @property {number|null} backgroundColorIndex index of the background color into the global color table (if the global color table is not available it's `null`) - can be used as a background before the first frame
* @property {Frame[]} frames each frame of the GIF (decoded into single images)
* @property {[string,string][]} comments comments in the file and were they where found (`[<area found>,<comment>]`)
* @property {ApplicationExtension[]} applicationExtensions all application extensions found
* @property {[number,Uint8Array][]} unknownExtensions all unknown extensions found (`[<identifier-1B>,<raw bytes>]`)
* @typedef {Object} ApplicationExtension
* @property {string} identifier 8 character string identifying the application
* @property {string} authenticationCode 3 bytes to authenticate the application identifier
* @property {Uint8Array} data the (raw) data of this application extension
* @typedef {Object} Frame
* @property {number} left the position of the left edge of this frame, in pixels, within the gif (from the left edge)
* @property {number} top the position of the top edge of this frame, in pixels, within the gif (from the top edge)
* @property {number} width the width of this frame in pixels
* @property {number} height the height of this frame in pixels
* @property {DisposalMethod} disposalMethod disposal method see {@link DisposalMethod}
* @property {number|null} transparentColorIndex the transparency index into the local or global color table (`null` if not encountered)
* @property {ImageData} image this frames image data
* @property {PlainTextData|null} plainTextData the text that will be displayed on screen with this frame (`null` if not encountered)
* @property {boolean} userInputDelayFlag if set waits for user input before rendering the next frame (timeout after delay if that is non-zero)
* @property {number} delayTime the delay of this frame in milliseconds (`0` is undefined (_wait for user input or skip frame_) - `10`ms precision)
* @property {boolean} sortFlag if the colors in the local color table are ordered after decreasing importance
* @property {[number,number,number][]} localColorTable the local color table for this frame
* @property {number} reserved _reserved for future use_ 2bits (from packed field in image descriptor block)
* @property {number} GCreserved _reserved for future use_ 3bits (from packed field in graphics control extension block)
* @typedef {Object} PlainTextData
* @property {number} left the position of the left edge of text grid (in pixels) within the GIF (from the left edge)
* @property {number} top the position of the top edge of text grid (in pixels) within the GIF (from the top edge)
* @property {number} width the width of the text grid (in pixels)
* @property {number} height the height of the text grid (in pixels)
* @property {number} charWidth the width (in pixels) of each cell (character) in text grid
* @property {number} charHeight the height (in pixels) of each cell (character) in text grid
* @property {number} foregroundColor the index into the global color table for the foreground color of the text
* @property {number} backgroundColor the index into the global color table for the background color of the text
* @property {string} text the text to render on screen
*/
/**@enum {number} (not flags, can only be one)*/
const DisposalMethod=Object.freeze({
/**unspecified > do nothing (default to {@linkcode DisposalMethod.DoNotDispose})*/Unspecified:0,
/**do not dispose > keep image / combine with next frame*/DoNotDispose:1,
/**restore to background color > opaque frame pixels get filled with background color or cleared (when it's the same as {@linkcode Frame.transparentColorIndex})*/RestoreBackgroundColor:2,
/**restore to previous > dispose frame data after rendering (revealing what was there before)*/RestorePrevious:3,
/**undefined > fallback to {@linkcode DisposalMethod.Unspecified}*/UndefinedA:4,
/**undefined > fallback to {@linkcode DisposalMethod.Unspecified}*/UndefinedB:5,
/**undefined > fallback to {@linkcode DisposalMethod.Unspecified}*/UndefinedC:6,
/**undefined > fallback to {@linkcode DisposalMethod.Unspecified}*/UndefinedD:7,
});
/**
* ## Decodes a GIF into its components for rendering on a canvas
* @param {string} gifURL - the URL of a GIF file
* @param {InterruptSignal} interruptSignal - pause/aboard fetching/parsing with this (via {@linkcode Interrupt})
* @param {(byteLength:number)=>(Promise<boolean>|boolean)} [sizeCheck] - Optional check if the loaded file should be processed if this yields `false` then it will reject with `file to large`
* @param {(percentageRead:number,frameIndex:number,frame:ImageData,framePos:[number,number],gifSize:[number,number])=>any} [progressCallback] - Optional callback for showing progress of decoding process (each frame) - if asynchronous, it waits for it to resolve before continuing decoding
* @returns {Promise<GIF>} the GIF with each frame decoded separately - may reject (throw) for the following reasons
* - {@linkcode interruptSignal} reference, when it triggers
* - fetch errors when trying to fetch the GIF from {@linkcode gifURL}:
* - - `fetch error: network error`
* - - `fetch error (connecting)` any unknown error during {@linkcode fetch}
* - - `fetch error: recieved STATUS_CODE` when URL yields a status code that's NOT between 200 and 299 (inclusive)
* - - `fetch error: could not read resource`
* - - `fetch error: resource to large` (not from {@linkcode sizeCheck})
* - - `fetch error (reading)` any unknown error during {@linkcode Response.arrayBuffer}
* - `file to large` when {@linkcode sizeCheck} yields `false`
* - `not a supported GIF file` when it's not a GIF file or the version is NOT `GIF89a`
* - `error while parsing frame [INDEX] "ERROR"` while decoding GIF - one of the following
* - - `GIF frame size is to large`
* - - `plain text extension without global color table`
* - - `undefined block found`
* - - `reading out of range` (unexpected end of file during decoding)
* - - `unknown error`
* @throws {TypeError} if {@linkcode gifURL} is not a string; {@linkcode interruptSignal} is not an interrupt signal; or {@linkcode sizeCheck} or {@linkcode progressCallback} are given but aren't functions
*/
const decodeGIF=async(gifURL,interruptSignal,sizeCheck,progressCallback)=>{
if(typeof gifURL!=="string")throw new TypeError("[decodeGIF] gifURL is not a string.");
if(!Interrupt.isSignal(interruptSignal))throw new TypeError("[decodeGIF] interruptSignal is not an interrupt signal.");
if(sizeCheck!=null&&typeof sizeCheck!=="function")throw new TypeError("[decodeGIF] sizeCheck is not a function.");
if(progressCallback!=null&&typeof progressCallback!=="function")throw new TypeError("[decodeGIF] progressCallback is not a function.");
if(await interruptSignal.check())throw interruptSignal;
/**
* @typedef {Object} ByteStream
* @property {number} pos current position in `data`
* @property {Uint8ClampedArray} data this streams raw data
* @property {()=>number} nextByte get the next byte and increase cursors position by one
* @property {()=>number} nextTwoBytes reads two bytes as one number (in reverse byte order)
* @property {(count:number)=>string} getString returns a string from current `pos` of length `count`
* @property {()=>string} readSubBlocks reads a set of blocks as a string
* @property {()=>Uint8Array} readSubBlocksBin reads a set of blocks as binary
* @property {()=>void} skipSubBlocks skips the next set of blocks in the stream
* @throws {RangeError} if any method tries to read out of bounds
*/
/**@type {readonly[0,4,2,1]}*/
const InterlaceOffsets=Object.freeze([0,4,2,1]);
/**@type {readonly[8,8,4,2]}*/
const InterlaceSteps=Object.freeze([8,8,4,2]);
/**@enum {number}*/
const GIFDataHeaders=Object.freeze({
/**extension introducer*/Extension:0x21,
/**extension label: application*/ApplicationExtension:0xFF,
/**extension label: graphic control*/GraphicsControlExtension:0xF9,
/**extension label: plain text*/PlainTextExtension:1,
/**extension label: comment*/CommentExtension:0xFE,
/**image seperator*/Image:0x2C,
/**trailer > end of file / GIF data*/EndOfFile:0x3B,
});
/**
* ### Get a color table of length {@linkcode count}
* @param {ByteStream} byteStream - GIF data stream
* @param {number} count - number of colours to read from {@linkcode byteStream}
* @returns {[number,number,number][]} an array of RGB color values
*/
const parseColorTable=(byteStream,count)=>{
/**@type {[number,number,number][]}*/
const colors=[];
for(let i=0;i<count;++i){
colors.push([
byteStream.data[byteStream.pos],
byteStream.data[byteStream.pos+1],
byteStream.data[byteStream.pos+2]
]);
byteStream.pos+=3;
}
return colors;
}
/**
* ### Parsing one block from GIF data stream
* @param {ByteStream} byteStream - GIF data stream
* @param {GIF} gif - GIF object to write to
* @param {(increment?:boolean)=>number} getFrameIndex - function to get current frame index in {@linkcode GIF.frames} (optionally increment before next cycle)
* @param {(replace?:string)=>string} getLastBlock - function to get last block read (optionally replace for next call)
* @returns {Promise<boolean>} true if EOF was reached
* @throws {EvalError} for the following reasons
* - GIF frame size is to large
* - plain text extension without global color table
* - undefined block found
* - unknown error
* @throws {RangeError} if {@linkcode byteStream} throws for out of bounds reading
* @throws {typeof interruptSignal} if {@linkcode interruptSignal} triggered
*/
const parseBlock=async(byteStream,gif,getFrameIndex,getLastBlock)=>{
switch(byteStream.nextByte()){
case GIFDataHeaders.EndOfFile:return true;
case GIFDataHeaders.Image:
//~ parse frame image - image descriptor
const frame=gif.frames[getFrameIndex(true)];
//~ image left position (2B) - position of the left edge of the frame (in pixels) within the GIF (from the left edge)
frame.left=byteStream.nextTwoBytes();
//~ image top position (2B) - position of the top edge of the frame (in pixels) within the GIF (from the top edge)
frame.top=byteStream.nextTwoBytes();
//~ image width (2B) - width of the frame (in pixels)
frame.width=byteStream.nextTwoBytes();
//~ image height (2B) - height of the frame (in pixels)
frame.height=byteStream.nextTwoBytes();
//~ packed byte (1B) >
const packedByte=byteStream.nextByte();
//~ > local color table flag (1b) - if set there will be a local color table otherwise use the global color table
const localColorTableFlag=(packedByte&0x80)===0x80;
//~ > interlaced flag (1b) - if set image is interlaced (4-pass interlace pattern)
const interlacedFlag=(packedByte&0x40)===0x40;
//~ > sort flag (1b) - if the colors in the local color table are ordered after decreasing importance
frame.sortFlag=(packedByte&0x20)===0x20;
//~ > reserved (2b) - reserved for future use
frame.reserved=(packedByte&0x18)>>>3;
//~ > size of local color table (3b) - number of bits minus 1 [1-8 bits] (256 colors max)
const localColorCount=1<<((packedByte&7)+1);
//~ read local color table if available
if(localColorTableFlag)frame.localColorTable=parseColorTable(byteStream,localColorCount);
//~ decode frame image data (GIF-LZW) - image data
/**
* #### Get color from color tables (transparent if {@linkcode index} is equal to the transparency index)
* uses local color table and fallback to global color table if {@linkcode index} is out of range or no local color table is present
* @param {number} index - index into global/local color table
* @returns {[number,number,number,number]} RGBA color value
*/
const getColor=index=>{
const[R,G,B]=localColorTableFlag&&index<frame.localColorTable.length?frame.localColorTable[index]:gif.globalColorTable[index];
return[R,G,B,index===frame.transparentColorIndex?0:255];
}
const image=(()=>{
try{return new ImageData(frame.width,frame.height,{colorSpace:"srgb"});}
catch(error){
if(error instanceof DOMException&&error.name==="IndexSizeError")return null;
throw error;
}
})();
if(image==null)throw new EvalError("GIF frame size is to large");
const minCodeSize=byteStream.nextByte();
const imageData=byteStream.readSubBlocksBin();
const clearCode=1<<minCodeSize;
/**
* #### Read {@linkcode len} bits from {@linkcode imageData} at {@linkcode pos}
* @param {number} pos - bit position in {@linkcode imageData}
* @param {number} len - bit length to read [1 to 12 bits]
* @returns {number} - {@linkcode len} bits at {@linkcode pos}
*/
const readBits=(pos,len)=>{
const bytePos=pos>>>3,
bitPos=pos&7;
return((imageData[bytePos]+(imageData[bytePos+1]<<8)+(imageData[bytePos+2]<<16))&(((1<<len)-1)<<bitPos))>>>bitPos;
};
if(interlacedFlag){
for(let code=0,size=minCodeSize+1,pos=0,dic=[[0]],pixelPos=0,lineIndex=0,pass=0;pass<4;){
if(InterlaceOffsets[pass]>=frame.height){
++pass;
pixelPos=0;
lineIndex=0;
continue;
}
const last=code;
code=readBits(pos,size);
pos+=size;
if(code===clearCode){
size=minCodeSize+1;
dic.length=clearCode+2;
for(let i=0;i<dic.length;++i)dic[i]=i<clearCode?[i]:[];
}else{
//~ clear code +1 = end of information code
if(code===clearCode+1)break;
if(code>=dic.length)dic.push(dic[last].concat(dic[last][0]));
else if(last!==clearCode)dic.push(dic[last].concat(dic[code][0]));
for(let i=0;i<dic[code].length;++i){
image.data.set(getColor(dic[code][i]),frame.width*4*(InterlaceOffsets[pass]+lineIndex*InterlaceSteps[pass])+pixelPos);
if((pixelPos+=4)>=frame.width*4){
pixelPos%=frame.width*4;
if(InterlaceOffsets[pass]+InterlaceSteps[pass]*++lineIndex>=frame.height){
//// await progressCallback?.((byteStream.pos+1+pos/8-imageData.length)/byteStream.data.length,getFrameIndex(),image,[frame.left,frame.top],[gif.width,gif.height]);
++pass;
pixelPos=0;
lineIndex=0;
}
}
}
if(dic.length>=(1<<size)&&size<12)++size;
}
if(InterlaceOffsets[pass]+InterlaceSteps[pass]*lineIndex>=frame.height){
//// await progressCallback?.((byteStream.pos+1+pos/8-imageData.length)/byteStream.data.length,getFrameIndex(),image,[frame.left,frame.top],[gif.width,gif.height]);
++pass;
pixelPos=0;
lineIndex=0;
}
if(await interruptSignal.check())throw interruptSignal;
}
await progressCallback?.((byteStream.pos+1)/byteStream.data.length,getFrameIndex(),frame.image=image,[frame.left,frame.top],[gif.width,gif.height]);
}else{
for(let code=0,size=minCodeSize+1,pos=0,dic=[[0]],pixelPos=-4;true;){
const last=code;
code=readBits(pos,size);
pos+=size;
if(code===clearCode){
size=minCodeSize+1;
dic.length=clearCode+2;
for(let i=0;i<dic.length;++i)dic[i]=i<clearCode?[i]:[];
}else{
//~ clear code +1 = end of information code
if(code===clearCode+1)break;
if(code>=dic.length)dic.push(dic[last].concat(dic[last][0]));
else if(last!==clearCode)dic.push(dic[last].concat(dic[code][0]));
for(let i=0;i<dic[code].length;++i)image.data.set(getColor(dic[code][i]),pixelPos+=4);
if(dic.length>=(1<<size)&&size<12)++size;
}
}
await progressCallback?.((byteStream.pos+1)/byteStream.data.length,getFrameIndex(),frame.image=image,[frame.left,frame.top],[gif.width,gif.height]);
if(await interruptSignal.check())throw interruptSignal;
}
getLastBlock(`frame [${getFrameIndex()}]`);
break;
case GIFDataHeaders.Extension:
switch(byteStream.nextByte()){
case GIFDataHeaders.GraphicsControlExtension:
//~ parse graphics control extension data - applies to the next frame in the byte stream
const frame=gif.frames[getFrameIndex()];
//~ block size (1B) - static 4 - byte size of the block up to (but excluding) the Block Terminator
++byteStream.pos;
//~ packed byte (1B) >
const packedByte=byteStream.nextByte();
//~ > reserved (3b) - reserved for future use
frame.GCreserved=(packedByte&0xE0)>>>5;
//~ > disposal method (3b) - [0-7] - 0: unspecified (no action) 1: combine (no dispose) 2: restore background 3: restore previous 4-7: undefined
frame.disposalMethod=(packedByte&0x1C)>>>2;
//~ > user input flag (1b) - if 1 then continues (rendering) after user input (or delay-time, if given)
frame.userInputDelayFlag=(packedByte&2)===2;
//~ > transparent color flag (1b) - indicates that, if set, the following transparency index should be used to ignore each color with this index in the following frame
const transparencyFlag=(packedByte&1)===1;
//~ delay time (2B) - if non-zero specifies the number of 1/100 seconds (here converted to milliseconds) to delay (rendering of) the following frame
frame.delayTime=byteStream.nextTwoBytes()*10;
//~ transparency index (1B) - color index of transparent color for following frame (only use if transparency flag is set - byte is always present)
if(transparencyFlag)frame.transparentColorIndex=byteStream.nextByte();
else ++byteStream.pos;
//~ block terminator (1B) - static 0 - marks end of graphics control extension block
++byteStream.pos;
getLastBlock(`graphics control extension for frame [${getFrameIndex()}]`);
break;
case GIFDataHeaders.ApplicationExtension:
//~ parse application extension - application-specific information
/**@type {ApplicationExtension}*/
const applicationExtension={};
//~ block size (1B) - static 11 - byte size of the block up to (but excluding) the application data blocks
++byteStream.pos;
//~ application identifier (8B) - 8 character string identifying the application (of this extension block)
applicationExtension.identifier=byteStream.getString(8);
//~ application authentication code (3B) - 3 bytes to authenticate the application identifier
applicationExtension.authenticationCode=byteStream.getString(3);
//~ application data blocks - the data of this application extension
applicationExtension.data=byteStream.readSubBlocksBin();
getLastBlock(`application extension [${gif.applicationExtensions.length}] ${applicationExtension.identifier}${applicationExtension.authenticationCode}`);
gif.applicationExtensions.push(applicationExtension);
break;
case GIFDataHeaders.CommentExtension:
//~ parse comment extension - one or more blocks each stating their size (1B) [1-255]
const pos=`[${byteStream.pos}] after ${getLastBlock()}`;
getLastBlock(`comment extension [${gif.comments.length}] at ${pos}`);
gif.comments.push([pos,byteStream.readSubBlocks()]);
break;
case GIFDataHeaders.PlainTextExtension:
//~ parse plain text extension - text to render with the following frame (needs global color table)
if(gif.globalColorTable.length===0)throw new EvalError("plain text extension without global color table");
//~ block size (1B) - static 12 - byte size of the block up to (but excluding) the plain text data blocks
++byteStream.pos;
/**@type {PlainTextData}*/
const plainTextData={};
//~ text grid left position (2B) - position of the left edge of text grid (in pixels) within the GIF (from the left edge)
plainTextData.left=byteStream.nextTwoBytes();
//~ text grid top position (2B) - position of the top edge of text grid (in pixels) within the GIF (from the top edge)
plainTextData.top=byteStream.nextTwoBytes();
//~ text grid width (2B) - width of the text grid (in pixels)
plainTextData.width=byteStream.nextTwoBytes();
//~ text grid height (2B) - height of the text grid (in pixels)
plainTextData.height=byteStream.nextTwoBytes();
//~ text character cell width (1B) - width (in pixels) of each cell (character) in text grid
plainTextData.charWidth=byteStream.nextByte();
//~ text character cell height (1B) - height (in pixels) of each cell (character) in text grid
plainTextData.charHeight=byteStream.nextByte();
//~ text foreground color index (1B) - index into the global color table for the foreground color of the text
plainTextData.foregroundColor=byteStream.nextByte();
//~ text background color index (1B) - index into the global color table for the background color of the text
plainTextData.backgroundColor=byteStream.nextByte();
//~ plain text data - one or more blocks each stating their size (1B) [1-255]
plainTextData.text=byteStream.readSubBlocks();
gif.frames[getFrameIndex()].plainTextData=plainTextData;
getLastBlock(`text extension for frame ${getFrameIndex()}]`);
break;
default:
const extID=byteStream.data[byteStream.pos-1];
getLastBlock(`unknown extension [${gif.unknownExtensions.length}] #${extID.toString(16).toUpperCase().padStart(2,'0')}`);
//~ read unknown extension with unknown length
gif.unknownExtensions.push([extID,byteStream.readSubBlocksBin()]);
break;
}
break;
default:throw new EvalError("undefined block found");
}
return false;
}
const response=await fetch(gifURL,{credentials:"omit",referrer:"",signal:interruptSignal.signal}).catch(err=>{
if(err===interruptSignal)throw interruptSignal;
if(err instanceof TypeError)throw"fetch error: network error";
throw"fetch error (connecting)";
});
if(response.redirected)console.info("Got redirected to: %s",response.url);
if(!response.ok)throw`fetch error: recieved ${response.status}`;
const raw=await response.arrayBuffer().catch(err=>{
if(err instanceof DOMException&&err.name==="AbortError")throw interruptSignal;
if(err instanceof TypeError)throw"fetch error: could not read resource";
if(err instanceof RangeError)throw"fetch error: resource to large";
throw"fetch error (reading)";
});
if(!(await(sizeCheck?.(raw.byteLength)??true)))throw"file to large";
/**@type {ByteStream}*/
const byteStream=Object.preventExtensions({
len:raw.byteLength,
pos:0,
data:new Uint8ClampedArray(raw),
nextByte(){
if(this.pos>=this.len)throw new RangeError("reading out of range");
return this.data[this.pos++];
},
nextTwoBytes(){
if((this.pos+=2)>this.len)throw new RangeError("reading out of range");
return this.data[this.pos-2]+(this.data[this.pos-1]<<8);
},
getString(count){
if(this.pos+count>this.len)throw new RangeError("reading out of range");
let s="";
for(;--count>=0;s+=String.fromCharCode(this.data[this.pos++]));
return s;
},
readSubBlocks(){
let blockString="",size=0;
do{
size=this.data[this.pos];
if((this.pos++)+size>this.len)throw new RangeError("reading out of range");
for(let count=size;--count>=0;blockString+=String.fromCharCode(this.data[this.pos++]));
}while(size!==0);
return blockString;
},
readSubBlocksBin(){
let size=0,len=0;
for(let offset=0;(size=this.data[this.pos+offset])!==0;offset+=size+1){
if(this.pos+offset>this.len)throw new RangeError("reading out of range");
len+=size;
}
const blockData=new Uint8Array(len);
for(let i=0;(size=this.data[this.pos++])!==0;)
for(let count=size;--count>=0;blockData[i++]=this.data[this.pos++]);
return blockData;
},
skipSubBlocks(){
for(;this.data[this.pos]!==0;this.pos+=this.data[this.pos]+1);
if(++this.pos>this.len)throw new RangeError("reading out of range");
}
});
if(await interruptSignal.check())throw interruptSignal;
//? https://www.w3.org/Graphics/GIF/spec-gif89a.txt
//? https://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp
//~ load stream and start decoding
/**@type {GIF} the output gif object*/
const gif={
width:0,
height:0,
totalTime:0,
colorRes:0,
pixelAspectRatio:0,
frames:[],
sortFlag:false,
globalColorTable:[],
backgroundColorIndex:null,
comments:[],
applicationExtensions:[],
unknownExtensions:[]
};
//~ signature (3B) and version (3B)
if(byteStream.getString(6)!=="GIF89a")throw"not a supported GIF file";
//~ width (2B) - in pixels
gif.width=byteStream.nextTwoBytes();
//~ height (2B) - in pixels
gif.height=byteStream.nextTwoBytes();
//~ packed byte (1B) >
const packedByte=byteStream.nextByte();
//~ > global color table flag (1b) - if there will be a global color table
const globalColorTableFlag=(packedByte&0x80)===0x80;
//~ > color resolution (3b) - bits per color minus 1 [1-8 bits] (256 colors max)
gif.colorRes=(packedByte&0x70)>>>4;
//~ > sort flag (1b) - if the colors in the global color table are ordered after decreasing importance
gif.sortFlag=(packedByte&8)===8;
//~ > size of global color table (3b) - number of bits minus 1 [1-8 bits] (256 colors max)
const globalColorCount=1<<((packedByte&7)+1);
//~ background color index (1B) - when global color table exists this points to the color (index) that should be used for pixels without color data (byte is always present)
if(globalColorTableFlag)gif.backgroundColorIndex=byteStream.nextByte();
else ++byteStream.pos;
//~ pixel aspect ratio (1B) - if non zero the pixel aspect ratio will be `(value + 15) / 64` from 4:1 to 1:4 in 1/64th increments
gif.pixelAspectRatio=byteStream.nextByte();
if(gif.pixelAspectRatio!==0)gif.pixelAspectRatio=(gif.pixelAspectRatio+15)/64;
//~ parse global color table if there is one
if(globalColorTableFlag)gif.globalColorTable=parseColorTable(byteStream,globalColorCount);
//~ parse other blocks ↓
let frameIndex=-1,
incrementFrameIndex=true,
lastBlock="parsing global GIF info";
/**
* ### Get the index of the current frame
* @param {boolean} [increment] - if the frame index should increse before the next cycle - default `false`
* @returns {number} current frame index
*/
const getframeIndex=increment=>{
if(increment??false)incrementFrameIndex=true;
return frameIndex;
};
/**
* ### Get the last block parsed
* @param {string} [replace] - if given replaces the current "last block" value with this
* @returns {string} last block parsed
*/
const getLastBlock=replace=>{
if(replace==null)return lastBlock;
return lastBlock=replace;
};
try{
do{
if(await interruptSignal.check())throw interruptSignal;
if(incrementFrameIndex){
gif.frames.push({
left:0,
top:0,
width:0,
height:0,
disposalMethod:DisposalMethod.Unspecified,
transparentColorIndex:null,
image:new ImageData(1,1,{colorSpace:"srgb"}),
plainTextData:null,
userInputDelayFlag:false,
delayTime:0,
sortFlag:false,
localColorTable:[],
reserved:0,
GCreserved:0,
});
++frameIndex;
incrementFrameIndex=false;
}
}while(!await parseBlock(byteStream,gif,getframeIndex,getLastBlock));
--gif.frames.length;
for(const frame of gif.frames){
//~ set total time to infinity if the user input delay flag is set and there is no timeout
if(frame.userInputDelayFlag&&frame.delayTime===0){
gif.totalTime=Infinity;
break;
}
gif.totalTime+=frame.delayTime;
}
return gif;
}catch(err){throw err===interruptSignal?interruptSignal:`error while parsing frame [${frameIndex}] "${(err instanceof EvalError)||(err instanceof RangeError)?err.message:"unknown error"}"`;}
};
/**
* ## Extract the animation loop amount from a {@linkcode GIF}
* Generally, for proper looping support, the `NETSCAPE2.0` extension must appear immediately after the global color table of the logical screen descriptor (at the beginning of the GIF file). Still, here, it doesn't matter where it was found.
* @param {GIF} gif - a parsed GIF object
* @returns {number} the loop amount of {@linkcode gif} as 16bit number (0 to 65'535 or `Infinity`)
*/
const getGIFLoopAmount=gif=>{
for(const ext of gif.applicationExtensions)
if(ext.authenticationCode==="2.0"&&ext.identifier==="NETSCAPE"){
const loop=ext.data[1]+(ext.data[2]<<8);
if(loop===0)return Infinity;
return loop;
}
return 0;
};
//~ _ _ ________ ___ _ _____ _ _
//~ | | | |_ _| \/ || | | ___| | | |
//~ | |_| | | | | . . || | | |__ | | ___ _ __ ___ ___ _ __ | |_ ___
//~ | _ | | | | |\/| || | | __|| |/ _ \ '_ ` _ \ / _ \ '_ \| __/ __|
//~ | | | | | | | | | || |____ | |___| | __/ | | | | | __/ | | | |_\__ \
//~ \_| |_/ \_/ \_| |_/\_____/ \____/|_|\___|_| |_| |_|\___|_| |_|\__|___/
//MARK: HTML Elements
/** HTML elements in DOM */
const html=Object.freeze({
/**
* @type {HTMLElement} DOM root (`<html>`)
* @description
* CSS variables for pan and zoom controls:
* - `--offset-view-left: 0px`
* - `--offset-view-top: 0px`
* - `--canvas-width: 0px`
* - `--canvas-scaler: 1.0`)
*
* JS custom events when importing/loading GIF:
* - `loadpreview` {@linkcode html.import.preview} loaded successfully from {@linkcode html.import.url} or {@linkcode html.import.file}
* - `loadcancel` import was canceled from {@linkcode html.import.url} or {@linkcode html.import.file} (preview failed)
* - `loadstart` import stared loading from {@linkcode html.import.confirm}
* - `loadend` import finished and first frame was drawn
* - `loaderror` import error and {@linkcode html.import.menu} shown
*/
root:document.documentElement,
/** @type {HTMLDivElement} Main container *///@ts-ignore element does exist in DOM
box:document.getElementById("box"),
/** @type {HTMLHeadingElement} Main page heading (element before {@linkcode html.view.view}) *///@ts-ignore element does exist in DOM
heading:document.getElementById("gifHeading"),
/** GIF view box (top left of the page) */
view:Object.freeze({
/** @type {HTMLDivElement} View box container *///@ts-ignore element does exist in DOM
view:document.getElementById("gifView"),
/** @type {HTMLCanvasElement} The main GIF canvas (HTML) *///@ts-ignore element does exist in DOM
htmlCanvas:document.getElementById("htmlCanvas"),
/** @type {CSSStyleDeclaration} Live CSS style map of {@linkcode html.view.htmlCanvas} *///@ts-ignore element does exist in DOM
canvasStyle:window.getComputedStyle(document.getElementById("htmlCanvas")),
/** @type {CanvasRenderingContext2D} The main GIF canvas (2D context of {@linkcode html.view.htmlCanvas}) *///@ts-ignore element does exist in DOM - checked for null further below
canvas:document.getElementById("htmlCanvas").getContext("2d",{colorSpace:"srgb"}),
/** @type {HTMLDivElement} container for the {@linkcode html.view.htmlCanvas} controls *///@ts-ignore element does exist in DOM
controls:document.getElementById("gifViewButtons"),
/** @type {HTMLInputElement} Button to `data-toggle` scale {@linkcode html.view.view} to browser width (on `⤢` off `🗙`) via class `full` *///@ts-ignore element does exist in DOM
fullWindow:document.getElementById("fullWindow"),
/** @type {HTMLInputElement} Button to `data-toggle` {@linkcode html.view.htmlCanvas} between 0 fit to {@linkcode html.view.view} `🞕` (default) and 1 actual size `🞑` (pan with drag controls `margin-left` & `-top` ("__px") (_offset to max half of canvas size_) and zoom `--scaler` (float)) via class `real` (and update `--canvas-width` ("__px")) *///@ts-ignore element does exist in DOM
fitWindow:document.getElementById("fitWindow"),
/** @type {HTMLInputElement} Button to `data-toggle` {@linkcode html.view.htmlCanvas} between 0 pixelated `🙾` (default) and 1 smooth `🙼` image rendering via class `smooth` *///@ts-ignore element does exist in DOM
imgSmoothing:document.getElementById("imgSmoothing"),
/** @type {HTMLSpanElement} Shows the FPS for {@linkcode html.view.htmlCanvas} (HTML: `FPS 00`) *///@ts-ignore element does exist in DOM
fps:document.getElementById("gifFPS")
}),
/** Time and frame sliders (under {@linkcode html.view}) */
frameTime:Object.freeze({
/** @type {HTMLInputElement} Disabled slider for GIF time progress in milliseconds (uses tickmarks of {@linkcode html.frameTime.timeTickmarks} - in HTML: `█ ms`) *///@ts-ignore element does exist in DOM
timeRange:document.getElementById("timeRange"),
/** @type {HTMLSpanElement} Displays current timestamp of GIF playback in milliseconds (next to {@linkcode html.frameTime.timeRange}) *///@ts-ignore element does exist in DOM
time:document.getElementById("time"),
/** @type {HTMLDataListElement} List of timestamps (milliseconds) for tickmarks (under {@linkcode html.frameTime.timeRange} - `<option>MS_TIMESTAMP</option>` starting at `0` - only shows for integer values) *///@ts-ignore element does exist in DOM
timeTickmarks:document.getElementById("timeTickmarks"),
/** @type {HTMLInputElement} Interactible slider for frame selection *///@ts-ignore element does exist in DOM
frameRange:document.getElementById("frameRange"),
/** @type {HTMLSpanElement} Displays current (zero-based) frame index (next to {@linkcode html.frameTime.frameRange}) *///@ts-ignore element does exist in DOM
frame:document.getElementById("frame")
}),
/** Override options for rendering (left of {@linkcode html.controls}) */
override:Object.freeze({
/** @type {HTMLInputElement} Button to `data-toggle` the visibility of the override menu (CSS) *///@ts-ignore element does exist in DOM
menu:document.getElementById("overrideMenu"),
/** @type {HTMLInputElement} Checkbox to toggle using pre-rendered frames (disabled until first time {@linkcode html.override.framesRender}; clear when loading new GIF) → render every frame of that list by replace (copy transparancy) *///@ts-ignore element does exist in DOM
frames:document.getElementById("overrideFrames"),
/** @type {HTMLInputElement} Button to pre render frames (clear when loading new GIF) → `data-render` is `1` when frames are rendered at least once (`0` otherwise) *///@ts-ignore element does exist in DOM
framesRender:document.getElementById("overrideFramesRender"),
/** @type {HTMLInputElement} Checkbox to toggle clearing after the last frame / before the first frame → ignore {@linkcode Frame.disposalMethod} and limit rendering start to frame 0 *///@ts-ignore element does exist in DOM
clear:document.getElementById("overrideClear"),
/** @type {HTMLInputElement} Checkbox to toggle background color transparent when restoring to background color ({@linkcode DisposalMethod.RestoreBackgroundColor}) → ignores {@linkcode Frame.transparentColorIndex} (not for text extension) *///@ts-ignore element does exist in DOM
background:document.getElementById("overrideBackground"),
/** @type {HTMLInputElement} Checkbox to toggle rendering the text extensions (do not render when checked) → ignores {@linkcode Frame.plainTextData} *///@ts-ignore element does exist in DOM
text:document.getElementById("overrideText"),
/** @type {HTMLInputElement} Checkbox to toggle reverse rendering order (left instead of right) → from {@linkcode global.frameIndexLast} decrement (not increment) frame index until {@linkcode global.frameIndex} is reached *///@ts-ignore element does exist in DOM
render:document.getElementById("overrideRender")
}),
/** GIF playback controls (under {@linkcode html.frameTime}) */
controls:Object.freeze({
/** @type {HTMLDivElement} Container of the control buttons - use this to visualize playback (class `reverse`/`paused`/`playing`) *///@ts-ignore element does exist in DOM
container:document.getElementById("playerControls"),
/** @type {HTMLInputElement} Button to go to the first frame of the GIF (and pause playback) *///@ts-ignore element does exist in DOM
seekStart:document.getElementById("seekStart"),
/** @type {HTMLInputElement} Button to go to the previous frame of the GIF (and pause playback) *///@ts-ignore element does exist in DOM
seekPrevious:document.getElementById("seekPrevious"),
/** @type {HTMLInputElement} Button to play GIF in reverse *///@ts-ignore element does exist in DOM
reverse:document.getElementById("reverse"),
/** @type {HTMLInputElement} Button to pause GIF playback *///@ts-ignore element does exist in DOM
pause:document.getElementById("pause"),
/** @type {HTMLInputElement} Button to start GIF playback *///@ts-ignore element does exist in DOM
play:document.getElementById("play"),
/** @type {HTMLInputElement} Button to go to the next frame of the GIF (and pause playback) *///@ts-ignore element does exist in DOM
seekNext:document.getElementById("seekNext"),
/** @type {HTMLInputElement} Button to go to the last frame of the GIF (and pause playback) *///@ts-ignore element does exist in DOM
seekEnd:document.getElementById("seekEnd")
}),
/** @type {HTMLInputElement} (number) multiplier for playback speed [`0` to `100`] - use class `stop` when it's `0` (right of {@linkcode html.controls}) *///@ts-ignore element does exist in DOM
speed:document.getElementById("speed"),
/** User input area (under {@linkcode html.controls}) */
userInput:Object.freeze({
/** @type {HTMLFieldSetElement} Container for user input controls (highlight via class `waiting` for user input and class `infinity` when infinite timeout) *///@ts-ignore element does exist in DOM
area:document.getElementById("userInputArea"),
/** @type {HTMLProgressElement} Progress bar to show timeout (milliseconds) for user input delay (remove `value` attribute when infinite timeout and `0` for no timeout - also update `max` accordingly (non-zero value)) *///@ts-ignore element does exist in DOM
timeout:document.getElementById("userInputTimeout"),
/** @type {HTMLSpanElement} Shows timeout for user input in milliseconds (see {@linkcode html.userInput.timeout} - `∞` when infinite timeout and empty for no timeout - in HTML: `█ ms`) *///@ts-ignore element does exist in DOM
timeoutTime:document.getElementById("userInputTimeoutTime"),
/** @type {HTMLInputElement} Button for user input, continues playback when waiting for user input *///@ts-ignore element does exist in DOM
input:document.getElementById("userInput"),
/** @type {HTMLInputElement} Button to `data-toggle` user input lock (1 ON / 0 OFF (default)) if ON, does not wait and continues playback instantly *///@ts-ignore element does exist in DOM
lock:document.getElementById("userInputLock")
}),
loop:Object.freeze({
/** @type {HTMLFieldSetElement} *///@ts-ignore element does exist in DOM
area:document.getElementById("loopArea"),
/** @type {HTMLSpanElement} Shows the current looping amount (HTML: `[CURRENT of MAX] Loops`) *///@ts-ignore element does exist in DOM
loopText:document.getElementById("loopText"),
/** @type {HTMLInputElement} Button to `data-toggle` force infinite looping *///@ts-ignore element does exist in DOM
toggle:document.getElementById("loopForce"),
}),
/** @type {HTMLDivElement} Main info panel container (right side of the page) *///@ts-ignore element does exist in DOM
infoPanels:document.getElementById("infoPanels"),
/** Collapsable areas in {@linkcode html.infoPanels} */
details:Object.freeze({
/** @type {HTMLDetailsElement} Collapsable area for the GIF info *///@ts-ignore element does exist in DOM
gifInfo:document.getElementById("detailsGIFInfo"),
/** @type {HTMLDetailsElement} Collapsable container of {@linkcode html.info.globalColorTable} *///@ts-ignore element does exist in DOM
globalColorTable:document.getElementById("detailsGlobalColorTable"),
/** @type {HTMLDetailsElement} Collapsable container of {@linkcode html.info.appExtList} *///@ts-ignore element does exist in DOM
appExtList:document.getElementById("detailsAppExtList"),
/** @type {HTMLDetailsElement} Collapsable container of {@linkcode html.info.commentsList} *///@ts-ignore element does exist in DOM
commentsList:document.getElementById("detailsCommentsList"),
/** @type {HTMLDetailsElement} Collapsable container of {@linkcode html.info.unExtList} *///@ts-ignore element does exist in DOM
unExtList:document.getElementById("detailsUnExtList"),
/** @type {HTMLDetailsElement} Collapsable area for the frame view (container of {@linkcode html.frame.view}) *///@ts-ignore element does exist in DOM
frameView:document.getElementById("detailsFrameView"),
/** @type {HTMLDetailsElement} Collapsable area for the frame info *///@ts-ignore element does exist in DOM
frameInfo:document.getElementById("detailsFrameInfo"),
/** @type {HTMLDetailsElement} Collapsable container of {@linkcode html.frame.localColorTable} *///@ts-ignore element does exist in DOM
localColorTable:document.getElementById("detailsFrameColorTable"),
/** @type {HTMLDetailsElement} Collapsable area for the frame text info *///@ts-ignore element does exist in DOM
frameText:document.getElementById("detailsFrameTextInfo")
}),
/** Progressbars for indicating gif decoding progress (does not allow canvas view controls) */
loading:Object.freeze({
/** @type {HTMLDivElement} Progress container over {@linkcode html.view.view} (of {@linkcode html.loading.gifText} and {@linkcode html.loading.gifProgress}) *///@ts-ignore element does exist in DOM
gif:document.getElementById("gifLoad"),
/** @type {HTMLParagraphElement} Progress text over {@linkcode html.loading.gifProgress} (format: `Frame I | P%` with padding to minimize flickering) *///@ts-ignore element does exist in DOM
gifText:document.getElementById("gifLoadText"),
/** @type {HTMLProgressElement} Progress bar under {@linkcode html.loading.gifText} (0 to 1 - class `done` afterwards) *///@ts-ignore element does exist in DOM
gifProgress:document.getElementById("gifLoadProgress"),
/** @type {HTMLDivElement} Progress container over {@linkcode html.frame.view} (of {@linkcode html.loading.frameText} and {@linkcode html.loading.frameProgress}) *///@ts-ignore element does exist in DOM
frame:document.getElementById("frameLoad"),
/** @type {HTMLParagraphElement} Progress text over {@linkcode html.loading.frameProgress} (format: `Frame I | P%` with padding to minimize flickering) *///@ts-ignore element does exist in DOM
frameText:document.getElementById("frameLoadText"),
/** @type {HTMLProgressElement} Progress bar under {@linkcode html.loading.frameText} (0 to 1 - class `done` afterwards) *///@ts-ignore element does exist in DOM
frameProgress:document.getElementById("frameLoadProgress"),
/** @type {HTMLInputElement} Button to `data-toggle` pausing decoding *///@ts-ignore element does exist in DOM
pause:document.getElementById("gifLoadPause"),
/** @type {HTMLInputElement} Button that aborts decoding and opens {@linkcode html.import.menu} again afterwards *///@ts-ignore element does exist in DOM
abort:document.getElementById("gifLoadAbort"),
}),
/** @type {HTMLInputElement} Button that opens the {@linkcode html.import.menu} (at the top of {@linkcode html.infoPanels}) *///@ts-ignore element does exist in DOM
open:document.getElementById("open"),
/** GIF info panel (collapsable) */
info:Object.freeze({
/** @type {HTMLTableCellElement} Shows the name of the GIF file *///@ts-ignore element does exist in DOM
fileName:document.getElementById("fileName"),
/** @type {HTMLSpanElement} Shows the total width of the GIF (in pixels) *///@ts-ignore element does exist in DOM
totalWidth:document.getElementById("totalWidth"),
/** @type {HTMLSpanElement} Shows the total height of the GIF (in pixels) *///@ts-ignore element does exist in DOM
totalHeight:document.getElementById("totalHeight"),
/** @type {HTMLTableCellElement} Shows the total number of frames of the GIF *///@ts-ignore element does exist in DOM
totalFrames:document.getElementById("totalFrames"),
/** @type {HTMLTableCellElement} Shows the total time of the GIF (in milliseconds) *///@ts-ignore element does exist in DOM
totalTime:document.getElementById("totalTime"),
/** @type {HTMLTableCellElement} Shows the pixel ascpect ratio of the GIF (in format `w:h` ie. `1:1`) *///@ts-ignore element does exist in DOM
pixelAspectRatio:document.getElementById("pixelAspectRatio"),
/** @type {HTMLTableCellElement} Shows the color resolution of the GIF (in bits) *///@ts-ignore element does exist in DOM
colorRes:document.getElementById("colorRes"),
/** @type {HTMLTableCellElement} The {@linkcode GIF.backgroundColorIndex} (use `-` if `null`) *///@ts-ignore element does exist in DOM
backgroundColorIndex:document.getElementById("backgroundColorIndex"),
/** @type {HTMLDivElement} List of colors in the global color table of the GIF (`<label title="Color index I - click to copy hex code">[I] <input type="color"></label>` (optionaly with class `background-flag` / `transparent-flag` and addition to title) for each color or `<span>Empty list (see local color tables)</span>`) *///@ts-ignore element does exist in DOM
globalColorTable:document.getElementById("globalColorTable"),
/** @type {HTMLDivElement} List of GIF application extensions in RAW binary (`<fieldset><legend title="Application-Extension identifier (8 characters) and authentication code (3 characters)">#I APPLICAT1.0</legend><span title="Description">unknown application extension</span> <input type="button" title="Click to copy raw binary to clipboard" value="Copy raw binary"></fieldset>` for each app. ext. or `<span>Empty list</span>`) *///@ts-ignore element does exist in DOM
appExtList:document.getElementById("appExtList"),
/** @type {HTMLDivElement} List of comments in the GIF file (`<div title="Comment #I found in GIF file at AREA_FOUND">Comment #I at AREA_FOUND<textarea readonly>COMMENT</textarea></div>` for each frame/comment or `<span>Empty list</span>`) *///@ts-ignore element does exist in DOM
commentsList: document.getElementById("commentsList"),
/** @type {HTMLDivElement} List of unknown extensions in the GIF file in RAW binary (`<div title="(Unknown) Extension identifier (1 character)"><span>#I W <small>(0x57)</small></span><input type="button" title="Click to copy raw binary to clipboard" value="Copy raw binary"></div>` for each unknown extension or `<span>Empty list</span>`) *///@ts-ignore element does exist in DOM
unExtList:document.getElementById("unExtList")
}),
/** GIF frame info panel (collapsable) */
frame:Object.freeze({
/** @type {HTMLDivElement} Collapsable (frame) view box container *///@ts-ignore element does exist in DOM
view:document.getElementById("frameView"),
/** @type {HTMLCanvasElement} The frame canvas (HTML) for the current frame *///@ts-ignore element does exist in DOM
htmlCanvas:document.getElementById("htmlFrameCanvas"),
/** @type {CSSStyleDeclaration} Live CSS style map of {@linkcode html.frame.htmlCanvas} *///@ts-ignore element does exist in DOM
canvasStyle:window.getComputedStyle(document.getElementById("htmlFrameCanvas")),
/** @type {CanvasRenderingContext2D} The frame canvas (2D context of {@linkcode html.frame.htmlCanvas}) for the current frame *///@ts-ignore element does exist in DOM - checked for null further below
canvas:document.getElementById("htmlFrameCanvas").getContext("2d",{colorSpace:"srgb"}),
/** @type {HTMLDivElement} container for the {@linkcode html.frame.hmtlCanvas} controls *///@ts-ignore element does exist in DOM
controls:document.getElementById("frameViewButtons"),
/** @type {HTMLInputElement} Button to `data-toggle` scale {@linkcode html.frame.view} to browser width (on `⤢` off `🗙`) via class `full` *///@ts-ignore element does exist in DOM
fullWindow:document.getElementById("frameFullWindow"),
/** @type {HTMLInputElement} Button to `data-toggle` {@linkcode html.frame.htmlCanvas} between 0 fit to {@linkcode html.frame.view} `🞕` (default) and 1 actual size `🞑` (pan with drag controls `margin-left` & `-top` ("__px") (_offset to max half of canvas size_) and zoom `--scaler` (float)) via class `real` (and update `--canvas-width` ("__px")) *///@ts-ignore element does exist in DOM
fitWindow:document.getElementById("frameFitWindow"),
/** @type {HTMLInputElement} Button to `data-toggle` {@linkcode html.frame.htmlCanvas} between 0 pixelated `🙾` (default) and 1 smooth `🙼` image rendering via class `smooth` *///@ts-ignore element does exist in DOM
imgSmoothing:document.getElementById("frameImgSmoothing"),
/** @type {HTMLSpanElement} Shows the FPS for {@linkcode html.frame.htmlCanvas} (HTML: `FPS 00`) *///@ts-ignore element does exist in DOM
fps:document.getElementById("frameFPS"),
/** @type {HTMLSpanElement} Shows the width of the current frame (in pixels) *///@ts-ignore element does exist in DOM
width:document.getElementById("frameWidth"),
/** @type {HTMLSpanElement} Shows the height of the current frame (in pixels) *///@ts-ignore element does exist in DOM
height:document.getElementById("frameHeight"),
/** @type {HTMLSpanElement} Shows the position of the current frame from the left edge of the GIF (in pixels) *///@ts-ignore element does exist in DOM
left:document.getElementById("frameLeft"),
/** @type {HTMLSpanElement} Shows the position of the current frame from the top edge of the GIF (in pixels) *///@ts-ignore element does exist in DOM
top:document.getElementById("frameTop"),
/** @type {HTMLTableCellElement} Shows the time in milliseconds this frame is displayed for *///@ts-ignore element does exist in DOM
time:document.getElementById("frameTime"),
/** @type {HTMLInputElement} Disabled checkbox to show if this frame is waiting for user input *///@ts-ignore element does exist in DOM
userInputFlag:document.getElementById("frameUserInputFlag"),
/** @type {HTMLTableCellElement} The {@linkcode Frame.transparentColorIndex} (use `-` if `null`) *///@ts-ignore element does exist in DOM
transparentColorIndex:document.getElementById("transparentColorIndex"),
/** @type {HTMLTableCellElement} Readonly, shows the disposal method of the current frame (index, text, and meaning) *///@ts-ignore element does exist in DOM
disposalMethod:document.getElementById("frameDisposalMethod"),
/** @type {HTMLTableCellElement} Reserved field {@linkcode Frame.reserved} (format: `- (0b00)`) *///@ts-ignore element does exist in DOM
frameReserved:document.getElementById("frameReserved"),
/** @type {HTMLTableCellElement} Reserved field {@linkcode Frame.GCreserved} (format: `- (0b000)`) *///@ts-ignore element does exist in DOM
frameGCReserved:document.getElementById("frameGCReserved"),
/** @type {HTMLDivElement} List of colors in the local color table of the current frame (`<label title="Color index I - click to copy hex code">[I] <input type="color"></label>` (optionaly with class `background-flag` / `transparent-flag` and addition to title) for each color or `<span>Empty list (see global color table)</span>`) *///@ts-ignore element does exist in DOM
localColorTable:document.getElementById("frameColorTable"),
/**
* Text information for this frame (only exist if a global color table is present)
* - characters other than `0x20` to `0xF7` are interpreted as `0x20` (space)
* - each character is rendered seperatly (in one cell each)
* - monospace font, size to cell height / width (measure font character w/h aspect and fit it to width if it's smaller)
* - cells are tiled from tp left, to right, then bottom (fractional cells are skiped)
* - if grid is filled and there are characters left, ignore them
*/
text:Object.freeze({
/** @type {HTMLDivElement} The text extension container (add class `empty` if current frame doesn't have a text extension) *///@ts-ignore element does exist in DOM
area:document.getElementById("frameTextArea"),
/** @type {HTMLTableCellElement} The text to display on top of the current frame *///@ts-ignore element does exist in DOM
text:document.getElementById("frameText"),
/** Grid information of this text */
grid:Object.freeze({
/** @type {HTMLSpanElement} The width of the text grid in pixels *///@ts-ignore element does exist in DOM
width:document.getElementById("frameTextWidth"),
/** @type {HTMLSpanElement} The height of the text grid in pixels *///@ts-ignore element does exist in DOM
height:document.getElementById("frameTextHeight"),
/** @type {HTMLSpanElement} The (top left) position of the text grid from the left edge of the GIF (logical screen) in pixels *///@ts-ignore element does exist in DOM
left:document.getElementById("frameTextLeft"),
/** @type {HTMLSpanElement} The (top left) position of the text grid from the top edge of the GIF (logical screen) in pixels *///@ts-ignore element does exist in DOM
top:document.getElementById("frameTextTop")
}),
/** Cell information of this text */
cell:Object.freeze({
/** @type {HTMLSpanElement} The width of each character cell in pixels (should tile the text grid perfectly) *///@ts-ignore element does exist in DOM
width:document.getElementById("frameTextCharWidth"),
/** @type {HTMLSpanElement} The height of each character cell in pixels (should tile the text grid perfectly) *///@ts-ignore element does exist in DOM
height:document.getElementById("frameTextCharHeight"),
}),
/** @type {HTMLInputElement} The foreground color of this text (index into global color table) *///@ts-ignore element does exist in DOM
foreground:document.getElementById("frameTextCharForeground"),
/** @type {HTMLInputElement} The background color of this text (index into global color table) *///@ts-ignore element does exist in DOM
background:document.getElementById("frameTextCharBackground")
})
}),
/** @type {HTMLDivElement} Popup to show something has been copied to clipboard ({@linkcode copyToClipboard}) *///@ts-ignore element does exist in DOM
copyNote:document.getElementById("copyNote"),
/** Import menu */
import:Object.freeze({
/** @type {HTMLDialogElement} The import menu element (use this to open or close dialog box) *///@ts-ignore element does exist in DOM
menu:document.getElementById("importMenu"),
/** @type {HTMLInputElement} The URL input field (deactivate this if {@linkcode html.import.file} is used) *///@ts-ignore element does exist in DOM
url:document.getElementById("importURL"),
/** @type {HTMLInputElement} The file input field (deactivate this if {@linkcode html.import.url} is used) *///@ts-ignore element does exist in DOM
file:document.getElementById("importFile"),
/** @type {HTMLImageElement} The IMG preview of the imported GIF *///@ts-ignore element does exist in DOM
preview:document.getElementById("importPreview"),
/** @type {HTMLParagraphElement} Show warnings, errors, or other notes here *///@ts-ignore element does exist in DOM
warn:document.getElementById("importWarn"),
/** @type {HTMLInputElement} Button to confirm import, close {@linkcode html.import.menu}, and start decoding *///@ts-ignore element does exist in DOM
confirm:document.getElementById("importConfirm"),
/** @type {HTMLInputElement} Button to abort import and close {@linkcode html.import.menu} *///@ts-ignore element does exist in DOM
abort:document.getElementById("importAbort")
}),
/** Confirm menu */
confirm:Object.freeze({
/** @type {HTMLDialogElement} The confirm menu element (use this to open or close dialog box) *///@ts-ignore element does exist in DOM
menu:document.getElementById("confirmMenu"),
/** @type {HTMLSpanElement} The text to confirm in the {@linkcode confirmMenu} *///@ts-ignore element does exist in DOM
text:document.getElementById("confirmText"),
/** @type {HTMLInputElement} Button to confirm action and close {@linkcode confirmMenu} *///@ts-ignore element does exist in DOM
confirm:document.getElementById("confirmConfirm"),
/** @type {HTMLInputElement} Button to abort action and close {@linkcode confirmMenu} *///@ts-ignore element does exist in DOM
abort:document.getElementById("confirmAbort")
})
});
if(html.view.canvas==null)throw new Error("[GIF decoder] Couldn't get GIF canvas 2D context");
if(html.frame.canvas==null)throw new Error("[GIF decoder] Couldn't get frame canvas 2D context");
//~ _____ __ _ ______ _ _
//~ / __ \ / _(_) | _ (_) | |
//~ | / \/ ___ _ __ | |_ _ _ __ _ __ ___ | | | |_ __ _| | ___ __ _
//~ | | / _ \| '_ \| _| | '__| '_ ` _ \ | | | | |/ _` | |/ _ \ / _` |
//~ | \__/\ (_) | | | | | | | | | | | | | | | |/ /| | (_| | | (_) | (_| |
//~ \____/\___/|_| |_|_| |_|_| |_| |_| |_| |___/ |_|\__,_|_|\___/ \__, |
//~ __/ |
//~ |___/
//MARK: Confirm Dialog
const confirmDialog=Object.seal(new class ConfirmDialog{
/** @type {((aborted:boolean)=>Promise<void>|void)|null} [Private] Current callback when dialog is closed (can be async) */
static _callback_=null;
/** @type {HTMLDialogElement} [Private] The dialog HTML element to open/close */
static _dialog_;
/** @type {HTMLSpanElement} [Private] The text HMTL element of {@linkcode _dialog_} to show a confirm message */
static _title_;
/** @type {HTMLInputElement} [Private] The confirm button HMTL element of {@linkcode _dialog_} */
static _confirm_;
/** @type {HTMLInputElement} [Private] The abort button HMTL element of {@linkcode _dialog_} */
static _abort_;
/** @type {boolean} [Private] If a dialog is currently still pending (can't open another one) */
static _running_=false;
/** @type {AbortController} [Private] abort controller to remove (all) event listeners (one way) */
static _listenerController_=new AbortController();
/** @type {boolean} If a dialog is currently still pending (can't open another one) */
get Running(){return ConfirmDialog._running_;}
/**
* ## Initializes {@linkcode ConfirmDialog} object
* @param {HTMLDialogElement} dialog - the confirm HMTL dialog element
* @param {HTMLSpanElement} text - the HTML text element of {@linkcode dialog} to show a confirm message
* @param {HTMLInputElement} confirm - the confirm button of {@linkcode dialog}
* @param {HTMLInputElement} abort - the abort button of {@linkcode dialog}
*/
constructor(dialog,text,confirm,abort){
ConfirmDialog._title_=text;
ConfirmDialog._dialog_=dialog;
ConfirmDialog._confirm_=confirm;
ConfirmDialog._abort_=abort;
ConfirmDialog._dialog_.addEventListener("cancel",async ev=>{ev.preventDefault();ConfirmDialog._abort_.click();},{passive:false,signal:ConfirmDialog._listenerController_.signal});
ConfirmDialog._confirm_.addEventListener("click",async()=>ConfirmDialog._exit_(false),{passive:true,signal:ConfirmDialog._listenerController_.signal});
ConfirmDialog._abort_.addEventListener("click",async()=>ConfirmDialog._exit_(true),{passive:true,signal:ConfirmDialog._listenerController_.signal});
}
/**
* ## [Private, async] Called by an event listener when the dialog was closed
* @param {boolean} aborted - if the dialog was aborted (`true`) or confirmed (`false`)
*/
static async _exit_(aborted){
ConfirmDialog._dialog_.close();
const call=ConfirmDialog._callback_;
ConfirmDialog._callback_=null;
ConfirmDialog._running_=false;
await call?.(aborted);
};
/**
* ## Shows a dialog that displays the {@linkcode action} to confirm
* check if another dialog is still pending via {@linkcode Running}
* @param {string} action - the action to confirm
* @param {(aborted:boolean)=>Promise<void>|void} callback - a (strict, passive, optionally async) function, called when the {@linkcode action} was confirmed (param `false`) or aborted (param `true`)
* @throws {Error} if another dialog is still pending