tiny-essentials 1.29.1 → 1.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/changelog/1/29/2.md +82 -0
  2. package/dist/v1/TinyBasicsEs.min.js +1 -1
  3. package/dist/v1/TinyEssentials.min.js +1 -1
  4. package/dist/v1/TinyFetchLoader.min.js +1 -0
  5. package/dist/v1/TinyImageLoader.min.js +1 -0
  6. package/dist/v1/TinyMediaCache.min.js +1 -0
  7. package/dist/v1/TinyMediaLoader.min.js +1 -0
  8. package/dist/v1/basics/index.cjs +5 -0
  9. package/dist/v1/basics/index.d.mts +2 -1
  10. package/dist/v1/basics/index.d.mts.map +1 -1
  11. package/dist/v1/basics/index.mjs +2 -1
  12. package/dist/v1/basics/media/youtube.cjs +275 -0
  13. package/dist/v1/basics/media/youtube.d.mts +107 -0
  14. package/dist/v1/basics/media/youtube.d.mts.map +1 -0
  15. package/dist/v1/basics/media/youtube.mjs +214 -0
  16. package/dist/v1/index.cjs +13 -0
  17. package/dist/v1/index.d.mts +6 -1
  18. package/dist/v1/index.d.mts.map +1 -1
  19. package/dist/v1/index.mjs +7 -2
  20. package/dist/v1/libs/fs/plugins/OPFS/StorageBucket.cjs +0 -1
  21. package/dist/v1/libs/fs/plugins/OPFS/StorageBucket.d.mts +0 -1
  22. package/dist/v1/libs/fs/plugins/OPFS/StorageBucket.d.mts.map +1 -1
  23. package/dist/v1/libs/fs/plugins/OPFS/StorageBucket.mjs +0 -1
  24. package/dist/v1/libs/fs/plugins/OPFS/index.cjs +0 -8
  25. package/dist/v1/libs/fs/plugins/OPFS/index.d.mts +0 -8
  26. package/dist/v1/libs/fs/plugins/OPFS/index.d.mts.map +1 -1
  27. package/dist/v1/libs/fs/plugins/OPFS/index.mjs +0 -8
  28. package/dist/v1/libs/html/TinyHtml.cjs +1 -1
  29. package/dist/v1/libs/html/TinyHtml.d.mts +2 -2
  30. package/dist/v1/libs/html/TinyHtml.d.mts.map +1 -1
  31. package/dist/v1/libs/html/TinyHtml.mjs +1 -1
  32. package/dist/v1/libs/html/loader/TinyMediaLoader.cjs +1999 -0
  33. package/dist/v1/libs/html/loader/TinyMediaLoader.d.mts +999 -0
  34. package/dist/v1/libs/html/loader/TinyMediaLoader.d.mts.map +1 -0
  35. package/dist/v1/libs/html/loader/TinyMediaLoader.mjs +1852 -0
  36. package/dist/v1/libs/html/loader/beta/formats/TinyAudioLoader.cjs +77 -0
  37. package/dist/v1/libs/html/loader/beta/formats/TinyAudioLoader.d.mts +103 -0
  38. package/dist/v1/libs/html/loader/beta/formats/TinyAudioLoader.d.mts.map +1 -0
  39. package/dist/v1/libs/html/loader/beta/formats/TinyAudioLoader.mjs +67 -0
  40. package/dist/v1/libs/html/loader/beta/formats/TinyContentLoader.cjs +630 -0
  41. package/dist/v1/libs/html/loader/beta/formats/TinyContentLoader.d.mts +138 -0
  42. package/dist/v1/libs/html/loader/beta/formats/TinyContentLoader.d.mts.map +1 -0
  43. package/dist/v1/libs/html/loader/beta/formats/TinyContentLoader.mjs +576 -0
  44. package/dist/v1/libs/html/loader/beta/formats/TinyVideoLoader.cjs +70 -0
  45. package/dist/v1/libs/html/loader/beta/formats/TinyVideoLoader.d.mts +100 -0
  46. package/dist/v1/libs/html/loader/beta/formats/TinyVideoLoader.d.mts.map +1 -0
  47. package/dist/v1/libs/html/loader/beta/formats/TinyVideoLoader.mjs +60 -0
  48. package/dist/v1/libs/html/loader/beta/probe/TinyMediaProbe.cjs +946 -0
  49. package/dist/v1/libs/html/loader/beta/probe/TinyMediaProbe.d.mts +67 -0
  50. package/dist/v1/libs/html/loader/beta/probe/TinyMediaProbe.d.mts.map +1 -0
  51. package/dist/v1/libs/html/loader/beta/probe/TinyMediaProbe.mjs +896 -0
  52. package/dist/v1/libs/html/loader/formats/TinyFetchLoader.cjs +225 -0
  53. package/dist/v1/libs/html/loader/formats/TinyFetchLoader.d.mts +149 -0
  54. package/dist/v1/libs/html/loader/formats/TinyFetchLoader.d.mts.map +1 -0
  55. package/dist/v1/libs/html/loader/formats/TinyFetchLoader.mjs +197 -0
  56. package/dist/v1/libs/html/loader/formats/TinyImageLoader.cjs +1454 -0
  57. package/dist/v1/libs/html/loader/formats/TinyImageLoader.d.mts +200 -0
  58. package/dist/v1/libs/html/loader/formats/TinyImageLoader.d.mts.map +1 -0
  59. package/dist/v1/libs/html/loader/formats/TinyImageLoader.mjs +1317 -0
  60. package/dist/v1/libs/html/loader/probe/TinyImageProbe.cjs +668 -0
  61. package/dist/v1/libs/html/loader/probe/TinyImageProbe.d.mts +83 -0
  62. package/dist/v1/libs/html/loader/probe/TinyImageProbe.d.mts.map +1 -0
  63. package/dist/v1/libs/html/loader/probe/TinyImageProbe.mjs +626 -0
  64. package/dist/v1/libs/html/loader/utils/TinyBufferReader.cjs +277 -0
  65. package/dist/v1/libs/html/loader/utils/TinyBufferReader.d.mts +118 -0
  66. package/dist/v1/libs/html/loader/utils/TinyBufferReader.d.mts.map +1 -0
  67. package/dist/v1/libs/html/loader/utils/TinyBufferReader.mjs +247 -0
  68. package/dist/v1/libs/html/loader/utils/TinyIsoBox.cjs +128 -0
  69. package/dist/v1/libs/html/loader/utils/TinyIsoBox.d.mts +49 -0
  70. package/dist/v1/libs/html/loader/utils/TinyIsoBox.d.mts.map +1 -0
  71. package/dist/v1/libs/html/loader/utils/TinyIsoBox.mjs +116 -0
  72. package/dist/v1/libs/html/loader/utils/TinyMediaCache.cjs +796 -0
  73. package/dist/v1/libs/html/loader/utils/TinyMediaCache.d.mts +354 -0
  74. package/dist/v1/libs/html/loader/utils/TinyMediaCache.d.mts.map +1 -0
  75. package/dist/v1/libs/html/loader/utils/TinyMediaCache.mjs +734 -0
  76. package/dist/v1/libs/html/loader/utils/TinyMediaMirror.cjs +302 -0
  77. package/dist/v1/libs/html/loader/utils/TinyMediaMirror.d.mts +256 -0
  78. package/dist/v1/libs/html/loader/utils/TinyMediaMirror.d.mts.map +1 -0
  79. package/dist/v1/libs/html/loader/utils/TinyMediaMirror.mjs +280 -0
  80. package/dist/v1/libs/html/loader/utils/TinyMediaProgress.cjs +194 -0
  81. package/dist/v1/libs/html/loader/utils/TinyMediaProgress.d.mts +138 -0
  82. package/dist/v1/libs/html/loader/utils/TinyMediaProgress.d.mts.map +1 -0
  83. package/dist/v1/libs/html/loader/utils/TinyMediaProgress.mjs +175 -0
  84. package/dist/v1/libs/tools/TinyPkgExportValidator.cjs +359 -55
  85. package/dist/v1/libs/tools/TinyPkgExportValidator.d.mts +64 -6
  86. package/dist/v1/libs/tools/TinyPkgExportValidator.d.mts.map +1 -1
  87. package/dist/v1/libs/tools/TinyPkgExportValidator.mjs +320 -57
  88. package/dist/v1/libs/worker/engine/TinyWebWorkerEngine.cjs +0 -1
  89. package/dist/v1/libs/worker/engine/TinyWebWorkerEngine.d.mts +0 -1
  90. package/dist/v1/libs/worker/engine/TinyWebWorkerEngine.d.mts.map +1 -1
  91. package/dist/v1/libs/worker/engine/TinyWebWorkerEngine.mjs +0 -1
  92. package/dist/v1/webTemplates/bootstrap/5.3/scss/background/_background-grid.scss +15 -0
  93. package/dist/v1/webTemplates/bootstrap/5.3/scss/components/buttons/_buttons-contrast.scss +208 -0
  94. package/dist/v1/webTemplates/bootstrap/5.3/scss/components/buttons/_buttons-ghost.scss +89 -0
  95. package/dist/v1/webTemplates/bootstrap/5.3/scss/components/icon/_cube-icon.scss +65 -0
  96. package/dist/v1/webTemplates/bootstrap/5.3/scss/components/icon/_sphere-pulse.scss +39 -0
  97. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_badges.scss +90 -0
  98. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_bootstrap-overrides.scss +495 -0
  99. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_buttons.scss +390 -0
  100. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_colors.scss +213 -0
  101. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_effects.scss +153 -0
  102. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_forms-tones.scss +69 -0
  103. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_forms.scss +323 -0
  104. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_global.scss +31 -0
  105. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_links.scss +47 -0
  106. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_tables.scss +25 -0
  107. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_theme.scss +21 -0
  108. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_tokens.scss +161 -0
  109. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_utilities.scss +81 -0
  110. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_variables.scss +63 -0
  111. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/plugins/_form-tone-mixins.scss +236 -0
  112. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/plugins/_sizing.scss +68 -0
  113. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/plugins/_tone-mixins.scss +392 -0
  114. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/plugins/_typography.scss +112 -0
  115. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/plugins/background/_tiny_gradient_1.scss +131 -0
  116. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/plugins/colors/_pink.scss +47 -0
  117. package/docs/v1/Exports.md +33 -2
  118. package/docs/v1/README.md +9 -0
  119. package/docs/v1/basics/youtube.md +204 -0
  120. package/docs/v1/libs/html/loader/TinyFetchLoader.md +114 -0
  121. package/docs/v1/libs/html/loader/TinyImageLoader.md +141 -0
  122. package/docs/v1/libs/html/loader/TinyMediaCache.md +166 -0
  123. package/docs/v1/libs/html/loader/TinyMediaLoader.md +300 -0
  124. package/docs/v1/libs/tools/TinyPkgExportValidator.md +55 -0
  125. package/docs/v1/webTemplates/bootstrap/5.3/DeepStrap.md +177 -0
  126. package/package.json +244 -8
  127. /package/dist/v1/webTemplates/bootstrap/5.3/scss/input/{Placeholder-Colors-Fix.scss → _placeholder-colors-fix.scss} +0 -0
  128. /package/dist/v1/webTemplates/bootstrap/5.3/scss/spacing/{Spacing-Plus.scss → _spacing-plus.scss} +0 -0
@@ -0,0 +1,1317 @@
1
+ /**
2
+ * @fileoverview Concrete loader for images. Exposes cache detection, aspect
3
+ * ratio helpers, full control over the decode step, and advanced animation parsing.
4
+ */
5
+ import TinyMediaLoader from '../TinyMediaLoader.mjs';
6
+ import TinyImageProbe from '../probe/TinyImageProbe.mjs';
7
+ import TinyMediaCache from '../utils/TinyMediaCache.mjs';
8
+ const EMPTY_GIF = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';
9
+ /**
10
+ * A reference to the early decoder type used for extracting metadata from raw bytes.
11
+ * @typedef {import('../TinyMediaLoader.mjs').MediaEarlyDecoder} MediaEarlyDecoder
12
+ */
13
+ /**
14
+ * @typedef {Object} AnimationFrame
15
+ * @property {string} url - The Object URL of the single-frame blob.
16
+ * @property {string} cacheKey - The unique key used in TinyMediaCache.
17
+ */
18
+ /**
19
+ * @typedef {Object} AnimationScriptStep
20
+ * @property {number} delay - Duration of the frame in milliseconds.
21
+ */
22
+ /**
23
+ * @typedef {Object} ImageLoaderOptions
24
+ * @property {string} [src] - The image source URL.
25
+ * @property {number} [timeout] - Maximum time in milliseconds before the load is aborted.
26
+ * @property {string|null} [crossOrigin] - The CORS mode applied to the image.
27
+ * @property {'sync'|'async'|'auto'} [decoding] - The decoding hint passed to the browser.
28
+ * @property {HTMLImageElement|null} [element] - An existing DOM element to adopt.
29
+ * @property {boolean} [earlyDecode] - Whether to decode the dimensions from the response headers using WebCodecs.
30
+ * @property {boolean} [progressive] - Whether to show the image progressively while streaming (default true).
31
+ * @property {boolean} [autoReload] - Whether an external `src` change triggers a reload.
32
+ * @property {boolean} [stream] - Whether to download the image through fetch for byte progress.
33
+ * @property {boolean} [useHeadRequest] - Whether to perform a HEAD request to fetch HTTP headers before downloading.
34
+ * @property {BodyInit} [body] - The request body forwarded to the transport (ignored by `GET`/`HEAD`).
35
+ * @property {boolean} [mirror] - Whether concurrent loads of the same source share a single download (default true).
36
+ * @property {import('../utils/TinyMediaCache.mjs').default|null} [cache] - An optional shared memory cache.
37
+ * @property {'none'|'native'|'script-only'|'controlled'} [animationControl] - How animated images are handled.
38
+ */
39
+ /**
40
+ * Reads the ISOBMFF boxes that live between two offsets.
41
+ * @param {Uint8Array} bytes - The full file buffer.
42
+ * @param {number} start - The offset of the first box.
43
+ * @param {number} end - The offset after the last box.
44
+ * @returns {Array<{ type: string, start: number, dataStart: number, dataEnd: number, end: number }>} The parsed box headers.
45
+ */
46
+ function readIsoBoxes(bytes, start, end) {
47
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
48
+ const result = [];
49
+ let offset = start;
50
+ while (offset + 8 <= end) {
51
+ let size = view.getUint32(offset);
52
+ const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]);
53
+ let headerSize = 8;
54
+ if (size === 1) {
55
+ if (offset + 16 > end)
56
+ break;
57
+ size = view.getUint32(offset + 8) * 0x100000000 + view.getUint32(offset + 12);
58
+ headerSize = 16;
59
+ }
60
+ else if (size === 0) {
61
+ size = end - offset;
62
+ }
63
+ if (size < headerSize || offset + size > end)
64
+ break;
65
+ result.push({
66
+ type,
67
+ start: offset,
68
+ dataStart: offset + headerSize,
69
+ dataEnd: offset + size,
70
+ end: offset + size,
71
+ });
72
+ offset += size;
73
+ }
74
+ return result;
75
+ }
76
+ /**
77
+ * Concatenates a list of byte arrays into a single buffer.
78
+ * @param {Uint8Array<ArrayBuffer>[]} chunks - The chunks to join.
79
+ * @returns {Uint8Array<ArrayBuffer>} The concatenated bytes.
80
+ */
81
+ function concatBytes(chunks) {
82
+ let length = 0;
83
+ for (const chunk of chunks)
84
+ length += chunk.length;
85
+ const out = new Uint8Array(length);
86
+ let offset = 0;
87
+ for (const chunk of chunks) {
88
+ out.set(chunk, offset);
89
+ offset += chunk.length;
90
+ }
91
+ return out;
92
+ }
93
+ /**
94
+ * Encodes an ASCII string as a byte array.
95
+ * @param {string} value - The ASCII string.
96
+ * @returns {Uint8Array<ArrayBuffer>} The encoded bytes.
97
+ */
98
+ function asciiBytes(value) {
99
+ const out = new Uint8Array(value.length);
100
+ for (let i = 0; i < value.length; i += 1)
101
+ out[i] = value.charCodeAt(i);
102
+ return out;
103
+ }
104
+ /**
105
+ * Encodes a big-endian unsigned 16-bit integer.
106
+ * @param {number} value - The value to encode.
107
+ * @returns {Uint8Array<ArrayBuffer>} The encoded bytes.
108
+ */
109
+ function u16be(value) {
110
+ return new Uint8Array([(value >> 8) & 0xff, value & 0xff]);
111
+ }
112
+ /**
113
+ * Encodes a big-endian unsigned 32-bit integer.
114
+ * @param {number} value - The value to encode.
115
+ * @returns {Uint8Array<ArrayBuffer>} The encoded bytes.
116
+ */
117
+ function u32be(value) {
118
+ return new Uint8Array([
119
+ (value >>> 24) & 0xff,
120
+ (value >>> 16) & 0xff,
121
+ (value >>> 8) & 0xff,
122
+ value & 0xff,
123
+ ]);
124
+ }
125
+ /**
126
+ * Serializes an ISOBMFF box from its type and payload chunks.
127
+ * @param {string} type - The 4-character box type.
128
+ * @param {...Uint8Array<ArrayBuffer>} chunks - The payload chunks.
129
+ * @returns {Uint8Array<ArrayBuffer>} The serialized box.
130
+ */
131
+ function buildIsoBox(type, ...chunks) {
132
+ const payload = concatBytes(chunks);
133
+ const out = new Uint8Array(8 + payload.length);
134
+ out.set(u32be(out.length), 0);
135
+ out.set(asciiBytes(type), 4);
136
+ out.set(payload, 8);
137
+ return out;
138
+ }
139
+ /**
140
+ * Wraps a raw AV1 sample into a self-contained single-frame AVIF file.
141
+ * @param {Uint8Array<ArrayBuffer>} av1c - The raw `av1C` configuration box.
142
+ * @param {number} width - The frame width in pixels.
143
+ * @param {number} height - The frame height in pixels.
144
+ * @param {Uint8Array<ArrayBuffer>} frame - The raw AV1 sample payload.
145
+ * @returns {Uint8Array<ArrayBuffer>} A valid single-frame AVIF file.
146
+ */
147
+ function buildAvifFrame(av1c, width, height, frame) {
148
+ const ftyp = buildIsoBox('ftyp', asciiBytes('avif'), u32be(0), asciiBytes('avif'), asciiBytes('mif1'), asciiBytes('miaf'));
149
+ /** @param {number} dataOffset */
150
+ const buildMeta = (dataOffset) => {
151
+ const hdlr = buildIsoBox('hdlr', u32be(0), u32be(0), asciiBytes('pict'), u32be(0), u32be(0), u32be(0), new Uint8Array([0]));
152
+ const pitm = buildIsoBox('pitm', u32be(0), u16be(1));
153
+ const iloc = buildIsoBox('iloc', u32be(0), new Uint8Array([0x44, 0x00]), u16be(1), u16be(1), u16be(0), u16be(1), u32be(dataOffset), u32be(frame.length));
154
+ const infe = buildIsoBox('infe', u32be(0x02000000), u16be(1), u16be(0), asciiBytes('av01'), new Uint8Array([0]));
155
+ const iinf = buildIsoBox('iinf', u32be(0), u16be(1), infe);
156
+ const ispe = buildIsoBox('ispe', u32be(0), u32be(width), u32be(height));
157
+ const ipco = buildIsoBox('ipco', av1c, ispe);
158
+ const ipma = buildIsoBox('ipma', u32be(0), u32be(1), u16be(1), new Uint8Array([2, 0x81, 0x02]));
159
+ const iprp = buildIsoBox('iprp', ipco, ipma);
160
+ return buildIsoBox('meta', u32be(0), hdlr, pitm, iloc, iinf, iprp);
161
+ };
162
+ const placeholder = buildMeta(0);
163
+ const dataOffset = ftyp.length + placeholder.length + 8;
164
+ const meta = buildMeta(dataOffset);
165
+ const mdat = buildIsoBox('mdat', frame);
166
+ return concatBytes([ftyp, meta, mdat]);
167
+ }
168
+ /**
169
+ * Loads a single image with full lifecycle and animation control.
170
+ * @extends {TinyMediaLoader<HTMLImageElement>}
171
+ */
172
+ class TinyImageLoader extends TinyMediaLoader {
173
+ /**
174
+ * Retrieves the tag name used to create the underlying element.
175
+ * @returns {string} The uppercase tag name, always `'IMG'`.
176
+ */
177
+ static get tagName() {
178
+ return 'IMG';
179
+ }
180
+ /** @type {HTMLImageElement|null} */
181
+ #image;
182
+ /** @type {string|null} */
183
+ #crossOrigin;
184
+ /** @type {'sync'|'async'|'auto'} */
185
+ #decoding;
186
+ /** @type {boolean} */
187
+ #stream;
188
+ // --- Animation Control State ---
189
+ /** @type {'none'|'native'|'script-only'|'controlled'} */
190
+ #animationControl;
191
+ /** @type {AnimationFrame[]} */
192
+ #frames = [];
193
+ /** @type {AnimationScriptStep[]} */
194
+ #animationScript = [];
195
+ /** @type {boolean} */
196
+ #isFrozen = false;
197
+ /** @type {boolean} */
198
+ #isPlaying = true;
199
+ /** @type {number} */
200
+ #playbackRate = 1.0;
201
+ /** @type {number} */
202
+ #currentFrameIndex = 0;
203
+ /** @type {ReturnType<typeof setTimeout>|null} */
204
+ #playbackTimer = null;
205
+ /** @type {string|null} */
206
+ #originalAnimatedUrl = null;
207
+ /** @type {TinyMediaCache|null} */
208
+ #frameCache = null;
209
+ /**
210
+ * Creates a new image loader and validates the provided options.
211
+ * @param {ImageLoaderOptions} [options] - The loader configuration.
212
+ * @throws {TypeError} If `crossOrigin` is not a string or null.
213
+ * @throws {TypeError} If `decoding` is not a string.
214
+ */
215
+ constructor(options = {}) {
216
+ if (options === null || typeof options !== 'object') {
217
+ throw new TypeError('The "options" argument must be an object.');
218
+ }
219
+ if (options.element !== null &&
220
+ options.element !== undefined &&
221
+ !(options.element instanceof HTMLImageElement)) {
222
+ throw new TypeError('The "element" option must be an HTMLImageElement or null.');
223
+ }
224
+ super({ useHeadRequest: false, ...options });
225
+ if (!options.cache)
226
+ this.#frameCache = new TinyMediaCache({ strategy: TinyMediaCache.Strategy.MANUAL });
227
+ const ANIMATION_MODES = ['none', 'native', 'script-only', 'controlled'];
228
+ const DECODING_MODES = ['sync', 'async', 'auto'];
229
+ const CROSS_ORIGIN = [null, 'anonymous', 'use-credentials'];
230
+ const { crossOrigin = null, decoding = 'async', stream = false, animationControl = 'none', } = options;
231
+ if (!ANIMATION_MODES.includes(animationControl)) {
232
+ throw new RangeError(`The "animationControl" option must be one of: ${ANIMATION_MODES.join(', ')}.`);
233
+ }
234
+ if (!DECODING_MODES.includes(decoding)) {
235
+ throw new RangeError(`The "decoding" option must be one of: ${DECODING_MODES.join(', ')}.`);
236
+ }
237
+ if (!CROSS_ORIGIN.includes(crossOrigin)) {
238
+ throw new RangeError('The "crossOrigin" option must be "anonymous", "use-credentials" or null.');
239
+ }
240
+ this.#crossOrigin = crossOrigin;
241
+ this.#decoding = decoding;
242
+ this.#image = null;
243
+ this.#animationControl = animationControl;
244
+ // To extract blobs and scripts, we MUST stream the bytes.
245
+ this.#stream = this.#animationControl !== 'none' ? true : Boolean(stream);
246
+ this._insertDecoder('image', (mime) => {
247
+ const probe = new TinyImageProbe(mime);
248
+ return {
249
+ write: (chunk) => {
250
+ const size = probe.push(chunk);
251
+ if (size)
252
+ this._emitMetadata(size, 'PROBE');
253
+ },
254
+ close: () => {
255
+ // When the download/stream ends, we call finalize()
256
+ // to collect all accumulated animation metadata.
257
+ const animationData = probe.finalize();
258
+ // Emits advanced information (frames, loopCount, duration)
259
+ if (animationData)
260
+ this._emitMetadata(animationData, 'PROBE_ANIMATION');
261
+ probe.close();
262
+ },
263
+ };
264
+ });
265
+ }
266
+ /**
267
+ * The underlying `<img>` element.
268
+ * @returns {HTMLImageElement|null} The current image element, or `null` before the load starts.
269
+ */
270
+ get image() {
271
+ return this.#image;
272
+ }
273
+ /**
274
+ * Indicates whether the image is downloaded through fetch for byte-level progress.
275
+ * @returns {boolean} `true` when streaming is enabled, otherwise `false`.
276
+ */
277
+ get stream() {
278
+ return this.#stream;
279
+ }
280
+ /**
281
+ * The intrinsic width of the image in pixels.
282
+ * @returns {number} The intrinsic width in pixels.
283
+ */
284
+ get naturalWidth() {
285
+ return this.#image?.naturalWidth ?? 0;
286
+ }
287
+ /**
288
+ * The intrinsic height of the image in pixels.
289
+ * @returns {number} The intrinsic height in pixels.
290
+ */
291
+ get naturalHeight() {
292
+ return this.#image?.naturalHeight ?? 0;
293
+ }
294
+ /**
295
+ * The aspect ratio of the image (width / height).
296
+ * @returns {number} The width divided by the height, or 0 when unknown.
297
+ */
298
+ get aspectRatio() {
299
+ const height = this.naturalHeight;
300
+ return height === 0 ? 0 : this.naturalWidth / height;
301
+ }
302
+ // --- Advanced Animation API ---
303
+ /**
304
+ * Freezes the animation. In native mode, falls back to the static 1st frame blob.
305
+ * @param {number|null} [frameIndex] - Frame index to freeze on (controlled mode only).
306
+ */
307
+ freeze(frameIndex = null) {
308
+ if (frameIndex !== null && (!Number.isInteger(frameIndex) || frameIndex < 0)) {
309
+ throw new RangeError('The "frameIndex" argument must be a non-negative integer or null.');
310
+ }
311
+ if (this.#animationControl === 'none' || this.#animationControl === 'script-only')
312
+ return;
313
+ this.#isFrozen = true;
314
+ if (this.#animationControl === 'native' && this.#frames.length > 0) {
315
+ const url = this.#frames[0].url;
316
+ if (this.#image) {
317
+ this._setInternalSrc(url);
318
+ this.#image.src = url;
319
+ }
320
+ }
321
+ else if (this.#animationControl === 'controlled') {
322
+ this.pause();
323
+ if (frameIndex !== null && frameIndex >= 0 && frameIndex < this.#frames.length) {
324
+ this.#currentFrameIndex = frameIndex;
325
+ }
326
+ if (this.#image && this.#frames[this.#currentFrameIndex]) {
327
+ const url = this.#frames[this.#currentFrameIndex].url;
328
+ this._setInternalSrc(url);
329
+ this.#image.src = url;
330
+ }
331
+ }
332
+ }
333
+ /**
334
+ * Unfreezes and resumes the animation.
335
+ */
336
+ unfreeze() {
337
+ if (this.#animationControl === 'none' || this.#animationControl === 'script-only')
338
+ return;
339
+ this.#isFrozen = false;
340
+ if (this.#animationControl === 'native') {
341
+ if (this.#image && this.#originalAnimatedUrl) {
342
+ this._setInternalSrc(this.#originalAnimatedUrl);
343
+ this.#image.src = this.#originalAnimatedUrl;
344
+ }
345
+ }
346
+ else if (this.#animationControl === 'controlled') {
347
+ this.play();
348
+ }
349
+ }
350
+ /**
351
+ * Plays the animation (controlled mode only).
352
+ */
353
+ play() {
354
+ if (this.#animationControl !== 'controlled')
355
+ return;
356
+ if (this.#playbackTimer)
357
+ return;
358
+ this.#isPlaying = true;
359
+ this.#isFrozen = false;
360
+ this.#playLoop();
361
+ }
362
+ /**
363
+ * Pauses the animation (controlled mode only).
364
+ */
365
+ pause() {
366
+ if (this.#animationControl !== 'controlled')
367
+ return;
368
+ this.#isPlaying = false;
369
+ if (this.#playbackTimer) {
370
+ clearTimeout(this.#playbackTimer);
371
+ this.#playbackTimer = null;
372
+ }
373
+ }
374
+ /**
375
+ * Gets or sets the playback speed modifier.
376
+ * @type {number}
377
+ */
378
+ get playbackRate() {
379
+ return this.#playbackRate;
380
+ }
381
+ set playbackRate(value) {
382
+ if (this.#animationControl !== 'controlled')
383
+ return;
384
+ this.#playbackRate = Math.max(0.1, Number(value) || 1);
385
+ }
386
+ /**
387
+ * Gets a deep copy of the current animation script or replaces it to modify behavior.
388
+ * @type {AnimationScriptStep[]}
389
+ */
390
+ get animationScript() {
391
+ return JSON.parse(JSON.stringify(this.#animationScript));
392
+ }
393
+ /**
394
+ * Replaces the animation script.
395
+ * @param {AnimationScriptStep[]} script - The new script.
396
+ * @throws {TypeError} If `script` is not an array of `{ delay: number }`.
397
+ * @throws {Error} If the loader is not in controlled mode.
398
+ */
399
+ set animationScript(script) {
400
+ if (this.#animationControl !== 'controlled') {
401
+ throw new Error('Animation script editing is only available in controlled mode.');
402
+ }
403
+ if (!Array.isArray(script)) {
404
+ throw new TypeError('The "script" value must be an array.');
405
+ }
406
+ this.#animationScript = Array.isArray(script) ? script : [];
407
+ }
408
+ // --- Core Lifecycle ---
409
+ /**
410
+ * Resolves the target element, wires the load listeners and decodes the image.
411
+ * @protected
412
+ * @returns {Promise<void>} A promise that resolves once the image is decoded and ready.
413
+ */
414
+ async _startLoad() {
415
+ // Frames, script and playback cursor are scoped to a single load. Releasing
416
+ // them here guarantees that a reload never mixes the frames of two images
417
+ // and that the frame cache keys stay stable across loads.
418
+ this._releaseAnimationFrames();
419
+ const image = /** @type {HTMLImageElement} */ (this._resolveElement());
420
+ this.#image = image;
421
+ if (this.#crossOrigin)
422
+ image.crossOrigin = this.#crossOrigin;
423
+ image.decoding = this.#decoding;
424
+ image.removeAttribute('src');
425
+ this._flushMutations();
426
+ let ready;
427
+ if (this.#stream) {
428
+ ready = await this.#streamInto(image);
429
+ }
430
+ else {
431
+ ready = this._waitForMedia(image, 'load');
432
+ image.src = this.src;
433
+ this._flushMutations();
434
+ }
435
+ await ready;
436
+ if (typeof image.decode === 'function') {
437
+ try {
438
+ await image.decode();
439
+ }
440
+ catch {
441
+ // A decode failure is non-fatal: the image is still renderable.
442
+ }
443
+ }
444
+ }
445
+ /**
446
+ * Builds the cache key that identifies a single frame of the current source.
447
+ * The index is the position of the frame in the source animation, never the
448
+ * current length of the frame array, so the key stays stable across loads.
449
+ * @param {number} index - The zero-based frame index.
450
+ * @returns {string} The cache key of the frame.
451
+ * @throws {RangeError} If `index` is not a non-negative integer.
452
+ */
453
+ #frameKey(index) {
454
+ if (!Number.isInteger(index) || index < 0) {
455
+ throw new RangeError('The "index" argument must be a non-negative integer.');
456
+ }
457
+ return `${this.src}:::frame=${index}`;
458
+ }
459
+ /**
460
+ * Returns the URL of a cached frame, or null when the frame was never sliced.
461
+ * @param {number} index - The zero-based frame index.
462
+ * @returns {string|null} The cached object URL.
463
+ * @throws {RangeError} If `index` is not a non-negative integer.
464
+ * @throws {Error} If no cache is available.
465
+ */
466
+ #getFrame(index) {
467
+ if (!Number.isInteger(index) || index < 0) {
468
+ throw new RangeError('The "index" argument must be a non-negative integer.');
469
+ }
470
+ const cache = this.cache ?? this.#frameCache;
471
+ if (!cache)
472
+ throw new Error('No cache is available to store animation frames.');
473
+ const entry = cache.get(this.#frameKey(index));
474
+ if (!entry) {
475
+ return null;
476
+ }
477
+ cache.acquire(this.#frameKey(index));
478
+ return entry.objectUrl;
479
+ }
480
+ /**
481
+ * Stores a sliced frame and returns its object URL.
482
+ * @param {number} index - The zero-based frame index.
483
+ * @param {Blob} blob - The single-frame blob.
484
+ * @returns {string} The object URL of the stored frame.
485
+ * @throws {RangeError} If `index` is not a non-negative integer.
486
+ * @throws {TypeError} If `blob` is not a Blob.
487
+ */
488
+ #setFrame(index, blob) {
489
+ if (!Number.isInteger(index) || index < 0) {
490
+ throw new RangeError('The "index" argument must be a non-negative integer.');
491
+ }
492
+ if (!(blob instanceof Blob)) {
493
+ throw new TypeError('The "blob" argument must be a Blob.');
494
+ }
495
+ const cache = this.cache ?? this.#frameCache;
496
+ if (!cache)
497
+ throw new Error('No cache is available to store animation frames.');
498
+ const key = this.#frameKey(index);
499
+ const entry = cache.set(key, blob, false, {}, this.src);
500
+ cache.acquire(key);
501
+ return entry.objectUrl;
502
+ }
503
+ /**
504
+ * Downloads the image through fetch, emitting byte-level progress.
505
+ * Updates the image source progressively during the stream.
506
+ * @param {HTMLImageElement} image - The target image element.
507
+ * @returns {Promise<Promise<void>>} A promise resolving to the final ready event load listener.
508
+ * @throws {TypeError} If `image` is not an HTMLImageElement.
509
+ * @throws {Error} If the network request fails.
510
+ */
511
+ async #streamInto(image) {
512
+ if (!(image instanceof HTMLImageElement)) {
513
+ throw new TypeError('The "image" argument must be an HTMLImageElement.');
514
+ }
515
+ const cached = this._acquireFromCache();
516
+ if (cached) {
517
+ this.emit('cachehit', cached);
518
+ this._setInternalSrc(cached.objectUrl);
519
+ this.#originalAnimatedUrl = cached.objectUrl;
520
+ // Se a imagem for animada e o controle não for 'none', precisamos popular this.#frames.
521
+ // Mesmo que seja 'native', precisamos do primeiro frame para a função freeze().
522
+ if (this.#animationControl !== 'none' &&
523
+ cached.metadata?.animation &&
524
+ this.#frames.length === 0) {
525
+ // Processamos o buffer da cache. O método #extractGifFrames cuidará de
526
+ // reutilizar URLs existentes ou adicionar novos frames à cache.
527
+ await this.#parseAnimationFromBuffer(await cached.blob.arrayBuffer());
528
+ }
529
+ if (this.#animationControl === 'controlled') {
530
+ this.#originalAnimatedUrl = null;
531
+ this.play();
532
+ return Promise.resolve();
533
+ }
534
+ const ready = this._waitForMedia(image, 'load');
535
+ image.src = cached.objectUrl;
536
+ this._flushMutations();
537
+ return ready;
538
+ }
539
+ let lastUpdate = performance.now();
540
+ /** @type {string|null} */
541
+ let previousUrl = null;
542
+ try {
543
+ const blob = await this._downloadStream(this.src, (_, chunks) => {
544
+ if (this.progressive && this.#animationControl !== 'controlled') {
545
+ const now = performance.now();
546
+ // Update visual image every 500ms to avoid thrashing CPU
547
+ if (now - lastUpdate > 500) {
548
+ lastUpdate = now;
549
+ const currentBlob = new Blob(chunks, { type: this.metadata.type || 'image/jpeg' });
550
+ const tempUrl = URL.createObjectURL(currentBlob);
551
+ if (previousUrl)
552
+ URL.revokeObjectURL(previousUrl);
553
+ previousUrl = tempUrl;
554
+ // Explicitly marks the temporary URL as internal so the MutationObserver
555
+ // ignores it and keeps the original source URL for the cache engine.
556
+ this._setInternalSrc(tempUrl);
557
+ image.src = tempUrl;
558
+ this._flushMutations();
559
+ }
560
+ }
561
+ });
562
+ // Extração de script e frames usando arrayBuffer sem utilizar a tag Canvas
563
+ if (this.#animationControl !== 'none' && this.metadata.animation) {
564
+ await this.#parseAnimationFromBuffer(await blob.arrayBuffer());
565
+ }
566
+ const url = this.mirrored ? this.#adoptSharedBlob(blob) : this._cacheBlob(blob);
567
+ this._setInternalSrc(url);
568
+ if (this.#animationControl === 'controlled') {
569
+ this.#originalAnimatedUrl = null;
570
+ this.play();
571
+ return Promise.resolve(); // Fake load ready, control loop assumes rendering
572
+ }
573
+ this.#originalAnimatedUrl = url;
574
+ const ready = this._waitForMedia(image, 'load');
575
+ image.src = url;
576
+ this._flushMutations();
577
+ return ready;
578
+ }
579
+ finally {
580
+ if (previousUrl)
581
+ URL.revokeObjectURL(previousUrl);
582
+ }
583
+ }
584
+ // --- Animation Binary Slicer (Canvas-Free) ---
585
+ /**
586
+ * Slices the animation frames out of a fully downloaded buffer.
587
+ * @param {ArrayBuffer} arrayBuffer - The full file bytes.
588
+ * @returns {Promise<void>} Resolves once the frames are sliced.
589
+ * @throws {TypeError} If `arrayBuffer` is not an ArrayBuffer.
590
+ */
591
+ async #parseAnimationFromBuffer(arrayBuffer) {
592
+ if (!(arrayBuffer instanceof ArrayBuffer)) {
593
+ throw new TypeError('The "arrayBuffer" argument must be an ArrayBuffer.');
594
+ }
595
+ const bytes = new Uint8Array(arrayBuffer);
596
+ if (bytes.length < 12)
597
+ return;
598
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {
599
+ this.#extractGifFrames(bytes);
600
+ }
601
+ else if (bytes[0] === 0x52 &&
602
+ bytes[1] === 0x49 &&
603
+ bytes[2] === 0x46 &&
604
+ bytes[3] === 0x46 &&
605
+ bytes[8] === 0x57 &&
606
+ bytes[9] === 0x45 &&
607
+ bytes[10] === 0x42 &&
608
+ bytes[11] === 0x50) {
609
+ this.#extractWebpFrames(bytes);
610
+ }
611
+ else if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
612
+ this.#extractApngFrames(bytes);
613
+ }
614
+ else if (bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) {
615
+ this.#extractAvifFrames(bytes);
616
+ }
617
+ }
618
+ /**
619
+ * Calculates the CRC-32 checksum for a given byte array.
620
+ * @param {Uint8Array} bytes - The bytes to checksum.
621
+ * @returns {number} The unsigned 32-bit checksum.
622
+ * @throws {TypeError} If `bytes` is not a Uint8Array.
623
+ */
624
+ #crc32(bytes) {
625
+ if (!(bytes instanceof Uint8Array)) {
626
+ throw new TypeError('The "bytes" argument must be a Uint8Array.');
627
+ }
628
+ let c = 0xffffffff;
629
+ for (let i = 0; i < bytes.length; i++) {
630
+ c ^= bytes[i];
631
+ for (let j = 0; j < 8; j++) {
632
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
633
+ }
634
+ }
635
+ return (c ^ 0xffffffff) >>> 0;
636
+ }
637
+ /**
638
+ * Slices an AVIF binary into multiple valid single-frame AVIF blobs.
639
+ * @param {Uint8Array<ArrayBuffer>} bytes - The full AVIF file.
640
+ * @returns {void}
641
+ * @throws {TypeError} If `bytes` is not a Uint8Array.
642
+ */
643
+ #extractAvifFrames(bytes) {
644
+ if (!(bytes instanceof Uint8Array)) {
645
+ throw new TypeError('The "bytes" argument must be a Uint8Array.');
646
+ }
647
+ const top = readIsoBoxes(bytes, 0, bytes.length);
648
+ const moov = top.find((box) => box.type === 'moov');
649
+ if (!moov)
650
+ return;
651
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
652
+ const traks = readIsoBoxes(bytes, moov.dataStart, moov.dataEnd).filter((box) => box.type === 'trak');
653
+ /** @type {{ stbl: { dataStart: number, dataEnd: number }, timescale: number }|null} */
654
+ let track = null;
655
+ for (const trak of traks) {
656
+ const mdia = readIsoBoxes(bytes, trak.dataStart, trak.dataEnd).find((b) => b.type === 'mdia');
657
+ if (!mdia)
658
+ continue;
659
+ const minf = readIsoBoxes(bytes, mdia.dataStart, mdia.dataEnd).find((b) => b.type === 'minf');
660
+ if (!minf)
661
+ continue;
662
+ const stbl = readIsoBoxes(bytes, minf.dataStart, minf.dataEnd).find((b) => b.type === 'stbl');
663
+ if (!stbl)
664
+ continue;
665
+ const mdhd = readIsoBoxes(bytes, mdia.dataStart, mdia.dataEnd).find((b) => b.type === 'mdhd');
666
+ let timescale = 1000;
667
+ if (mdhd) {
668
+ const version = bytes[mdhd.dataStart];
669
+ timescale =
670
+ version === 1 ? view.getUint32(mdhd.dataStart + 20) : view.getUint32(mdhd.dataStart + 12);
671
+ }
672
+ track = { stbl, timescale };
673
+ break;
674
+ }
675
+ if (!track)
676
+ return;
677
+ const children = readIsoBoxes(bytes, track.stbl.dataStart, track.stbl.dataEnd);
678
+ const stszBox = children.find((b) => b.type === 'stsz');
679
+ const stcoBox = children.find((b) => b.type === 'stco');
680
+ const co64Box = children.find((b) => b.type === 'co64');
681
+ const stscBox = children.find((b) => b.type === 'stsc');
682
+ const sttsBox = children.find((b) => b.type === 'stts');
683
+ const stsdBox = children.find((b) => b.type === 'stsd');
684
+ if (!stszBox || !stscBox || !stsdBox)
685
+ return;
686
+ const sampleSizes = [];
687
+ {
688
+ const base = stszBox.dataStart + 4;
689
+ const uniform = view.getUint32(base);
690
+ const count = view.getUint32(base + 4);
691
+ for (let i = 0; i < count; i += 1) {
692
+ sampleSizes.push(uniform > 0 ? uniform : view.getUint32(base + 8 + i * 4));
693
+ }
694
+ }
695
+ const chunkOffsets = [];
696
+ if (stcoBox) {
697
+ const count = view.getUint32(stcoBox.dataStart + 4);
698
+ for (let i = 0; i < count; i += 1) {
699
+ chunkOffsets.push(view.getUint32(stcoBox.dataStart + 8 + i * 4));
700
+ }
701
+ }
702
+ else if (co64Box) {
703
+ const count = view.getUint32(co64Box.dataStart + 4);
704
+ for (let i = 0; i < count; i += 1) {
705
+ const high = view.getUint32(co64Box.dataStart + 8 + i * 8);
706
+ const low = view.getUint32(co64Box.dataStart + 12 + i * 8);
707
+ chunkOffsets.push(high * 0x100000000 + low);
708
+ }
709
+ }
710
+ const stsc = [];
711
+ {
712
+ const count = view.getUint32(stscBox.dataStart + 4);
713
+ for (let i = 0; i < count; i += 1) {
714
+ const base = stscBox.dataStart + 8 + i * 12;
715
+ stsc.push({
716
+ firstChunk: view.getUint32(base),
717
+ samplesPerChunk: view.getUint32(base + 4),
718
+ });
719
+ }
720
+ }
721
+ const sampleDurations = [];
722
+ if (sttsBox) {
723
+ const count = view.getUint32(sttsBox.dataStart + 4);
724
+ for (let i = 0; i < count; i += 1) {
725
+ const base = sttsBox.dataStart + 8 + i * 8;
726
+ const entryCount = view.getUint32(base);
727
+ const delta = view.getUint32(base + 4);
728
+ for (let j = 0; j < entryCount; j += 1)
729
+ sampleDurations.push(delta);
730
+ }
731
+ }
732
+ const sampleOffsets = [];
733
+ {
734
+ let sampleIndex = 0;
735
+ for (let i = 0; i < stsc.length; i += 1) {
736
+ const { firstChunk, samplesPerChunk } = stsc[i];
737
+ const lastChunk = i + 1 < stsc.length ? stsc[i + 1].firstChunk - 1 : chunkOffsets.length;
738
+ for (let chunk = firstChunk; chunk <= lastChunk; chunk += 1) {
739
+ let offset = chunkOffsets[chunk - 1] ?? 0;
740
+ for (let s = 0; s < samplesPerChunk; s += 1) {
741
+ sampleOffsets.push(offset);
742
+ offset += sampleSizes[sampleIndex] ?? 0;
743
+ sampleIndex += 1;
744
+ }
745
+ }
746
+ }
747
+ }
748
+ const entries = readIsoBoxes(bytes, stsdBox.dataStart + 8, stsdBox.dataEnd);
749
+ const sampleEntry = entries[0];
750
+ if (!sampleEntry)
751
+ return;
752
+ const width = view.getUint16(sampleEntry.dataStart + 24);
753
+ const height = view.getUint16(sampleEntry.dataStart + 26);
754
+ const entryChildren = readIsoBoxes(bytes, sampleEntry.dataStart + 78, sampleEntry.dataEnd);
755
+ const av1cBox = entryChildren.find((b) => b.type === 'av1C');
756
+ if (!av1cBox)
757
+ return;
758
+ /** @type {Uint8Array<ArrayBuffer>} */
759
+ const av1c = bytes.subarray(av1cBox.start, av1cBox.end);
760
+ const frameCount = Math.min(sampleSizes.length, sampleOffsets.length);
761
+ for (let i = 0; i < frameCount; i += 1) {
762
+ const size = sampleSizes[i];
763
+ const offset = sampleOffsets[i];
764
+ const duration = sampleDurations[i] ?? 0;
765
+ let delay = track.timescale > 0 ? Math.round((duration / track.timescale) * 1000) : 100;
766
+ if (delay <= 10)
767
+ delay = 100;
768
+ this.#animationScript.push({ delay });
769
+ if (this.#animationControl !== 'script-only') {
770
+ let url = this.#getFrame(i);
771
+ if (!url) {
772
+ /** @type {Uint8Array<ArrayBuffer>} */
773
+ const frameData = bytes.subarray(offset, offset + size);
774
+ const file = buildAvifFrame(av1c, width, height, frameData);
775
+ const blob = new Blob([file], { type: 'image/avif' });
776
+ url = this.#setFrame(i, blob);
777
+ }
778
+ this.#frames.push({ url, cacheKey: this.#frameKey(i) });
779
+ }
780
+ if (this.#animationControl === 'native')
781
+ break;
782
+ }
783
+ }
784
+ /**
785
+ * Slices a WebP binary into multiple valid single-frame WebP blobs.
786
+ * @param {Uint8Array<ArrayBuffer>} bytes - The full WebP file.
787
+ * @returns {void}
788
+ * @throws {TypeError} If `bytes` is not a Uint8Array.
789
+ */
790
+ #extractWebpFrames(bytes) {
791
+ if (!(bytes instanceof Uint8Array)) {
792
+ throw new TypeError('The "bytes" argument must be a Uint8Array.');
793
+ }
794
+ if (bytes.length < 12)
795
+ return;
796
+ let offset = 12;
797
+ let vp8x = null;
798
+ let anim = null;
799
+ const preDataChunks = [];
800
+ const frames = [];
801
+ while (offset + 8 <= bytes.length) {
802
+ const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 8);
803
+ const fourcc = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
804
+ const size = view.getUint32(4, true);
805
+ const payloadEnd = offset + 8 + size + (size % 2);
806
+ if (payloadEnd > bytes.length)
807
+ break;
808
+ const chunkBytes = bytes.subarray(offset, payloadEnd);
809
+ if (fourcc === 'VP8X') {
810
+ vp8x = chunkBytes;
811
+ }
812
+ else if (fourcc === 'ANIM') {
813
+ anim = chunkBytes;
814
+ }
815
+ else if (fourcc === 'ANMF') {
816
+ const payload = offset + 8;
817
+ const dur = bytes[payload + 12] | (bytes[payload + 13] << 8) | (bytes[payload + 14] << 16);
818
+ frames.push({ chunk: chunkBytes, delay: dur });
819
+ }
820
+ else {
821
+ if (!frames.length && fourcc !== 'VP8 ' && fourcc !== 'VP8L') {
822
+ preDataChunks.push(chunkBytes);
823
+ }
824
+ }
825
+ offset = payloadEnd;
826
+ }
827
+ if (frames.length === 0)
828
+ return;
829
+ for (let i = 0; i < frames.length; i++) {
830
+ const frame = frames[i];
831
+ let delay = frame.delay;
832
+ if (delay <= 10)
833
+ delay = 100;
834
+ this.#animationScript.push({ delay });
835
+ if (this.#animationControl !== 'script-only') {
836
+ let url = this.#getFrame(i);
837
+ if (!url) {
838
+ /** @type {Uint8Array<ArrayBuffer>[]} */
839
+ const outChunks = [];
840
+ if (vp8x)
841
+ outChunks.push(vp8x);
842
+ if (anim)
843
+ outChunks.push(anim);
844
+ for (const chunk of preDataChunks)
845
+ outChunks.push(chunk);
846
+ outChunks.push(frame.chunk);
847
+ let totalPayloadSize = 4;
848
+ for (const c of outChunks)
849
+ totalPayloadSize += c.length;
850
+ const riffHeader = new Uint8Array(12);
851
+ const riffView = new DataView(riffHeader.buffer);
852
+ riffHeader.set([0x52, 0x49, 0x46, 0x46], 0);
853
+ riffView.setUint32(4, totalPayloadSize, true);
854
+ riffHeader.set([0x57, 0x45, 0x42, 0x50], 8);
855
+ const blob = new Blob([riffHeader, ...outChunks], { type: 'image/webp' });
856
+ url = this.#setFrame(i, blob);
857
+ }
858
+ this.#frames.push({ url, cacheKey: this.#frameKey(i) });
859
+ }
860
+ if (this.#animationControl === 'native')
861
+ break;
862
+ }
863
+ }
864
+ /**
865
+ * Slices an APNG binary into multiple valid single-frame SVG blobs.
866
+ *
867
+ * APNG frames are deltas: a frame may cover only the sub-rectangle that
868
+ * changed, and `blend_op` / `dispose_op` decide how the frame interacts with
869
+ * the accumulated canvas. The SVG produced for frame `i` therefore replays
870
+ * every draw and every erase that happened up to `i`, which reproduces the
871
+ * exact pixels of the original APNG without a canvas element.
872
+ *
873
+ * @param {Uint8Array<ArrayBuffer>} bytes - The full APNG file.
874
+ * @returns {void}
875
+ * @throws {TypeError} If `bytes` is not a Uint8Array.
876
+ */
877
+ #extractApngFrames(bytes) {
878
+ if (!(bytes instanceof Uint8Array)) {
879
+ throw new TypeError('The "bytes" argument must be a Uint8Array.');
880
+ }
881
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
882
+ const IEND_BYTES = new Uint8Array([
883
+ 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
884
+ ]);
885
+ if (bytes.length < 8)
886
+ return;
887
+ /**
888
+ * @typedef {Object} ApngFrame
889
+ * @property {number} delay - Frame duration in milliseconds.
890
+ * @property {{ type: string, bytes: Uint8Array<ArrayBuffer> }[]} dataChunks - IDAT/fdAT chunks.
891
+ * @property {number} width - Frame width in pixels.
892
+ * @property {number} height - Frame height in pixels.
893
+ * @property {number} x - Frame X offset inside the canvas.
894
+ * @property {number} y - Frame Y offset inside the canvas.
895
+ * @property {number} disposeOp - 0 = NONE, 1 = BACKGROUND, 2 = PREVIOUS.
896
+ * @property {number} blendOp - 0 = SOURCE, 1 = OVER.
897
+ */
898
+ /** @type {ApngFrame[]} */
899
+ const frames = [];
900
+ /** @type {Uint8Array<ArrayBuffer>[]} */
901
+ const preDataChunks = [];
902
+ let iend = null;
903
+ let fullWidth = 0;
904
+ let fullHeight = 0;
905
+ let offset = 8;
906
+ let seenIdatOrFctl = false;
907
+ /** @type {ApngFrame|null} */
908
+ let currentFrame = null;
909
+ while (offset + 8 <= bytes.length) {
910
+ const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 8);
911
+ const size = view.getUint32(0);
912
+ const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]);
913
+ const chunkEnd = offset + 12 + size;
914
+ if (chunkEnd > bytes.length)
915
+ break;
916
+ const chunkBytes = bytes.subarray(offset, chunkEnd);
917
+ if (type === 'IHDR') {
918
+ const ihdrView = new DataView(chunkBytes.buffer, chunkBytes.byteOffset, chunkBytes.byteLength);
919
+ fullWidth = ihdrView.getUint32(8);
920
+ fullHeight = ihdrView.getUint32(12);
921
+ preDataChunks.push(chunkBytes);
922
+ }
923
+ else if (type === 'acTL') {
924
+ // The original animation controller is replaced by the frame script.
925
+ }
926
+ else if (type === 'fcTL') {
927
+ seenIdatOrFctl = true;
928
+ if (currentFrame)
929
+ frames.push(currentFrame);
930
+ currentFrame = null;
931
+ if (size >= 26) {
932
+ const fcTLView = new DataView(bytes.buffer, bytes.byteOffset + offset + 8, 26);
933
+ const delayNum = fcTLView.getUint16(20);
934
+ const delayDen = fcTLView.getUint16(22) || 100;
935
+ currentFrame = {
936
+ delay: delayDen === 0 ? 100 : Math.round((delayNum / delayDen) * 1000),
937
+ dataChunks: [],
938
+ width: fcTLView.getUint32(4),
939
+ height: fcTLView.getUint32(8),
940
+ x: fcTLView.getUint32(12),
941
+ y: fcTLView.getUint32(16),
942
+ disposeOp: fcTLView.getUint8(24),
943
+ blendOp: fcTLView.getUint8(25),
944
+ };
945
+ }
946
+ }
947
+ else if (type === 'IDAT') {
948
+ seenIdatOrFctl = true;
949
+ if (currentFrame)
950
+ currentFrame.dataChunks.push({ type: 'IDAT', bytes: chunkBytes });
951
+ }
952
+ else if (type === 'fdAT') {
953
+ seenIdatOrFctl = true;
954
+ if (currentFrame)
955
+ currentFrame.dataChunks.push({ type: 'fdAT', bytes: chunkBytes });
956
+ }
957
+ else if (type === 'IEND') {
958
+ iend = chunkBytes;
959
+ break;
960
+ }
961
+ else if (!seenIdatOrFctl) {
962
+ preDataChunks.push(chunkBytes);
963
+ }
964
+ offset = chunkEnd;
965
+ }
966
+ if (currentFrame)
967
+ frames.push(currentFrame);
968
+ if (frames.length === 0)
969
+ return;
970
+ /**
971
+ * Builds the base64 PNG of a single APNG frame.
972
+ * @param {ApngFrame} frame - The frame to encode.
973
+ * @returns {string} The base64-encoded PNG.
974
+ */
975
+ const buildFramePng = (frame) => {
976
+ const patchedPreData = preDataChunks.map((chunk) => {
977
+ const chunkType = String.fromCharCode(chunk[4], chunk[5], chunk[6], chunk[7]);
978
+ if (chunkType !== 'IHDR')
979
+ return chunk;
980
+ const newIhdr = new Uint8Array(chunk);
981
+ const view = new DataView(newIhdr.buffer, newIhdr.byteOffset, newIhdr.byteLength);
982
+ view.setUint32(8, frame.width);
983
+ view.setUint32(12, frame.height);
984
+ view.setUint32(newIhdr.length - 4, this.#crc32(newIhdr.subarray(4, newIhdr.length - 4)));
985
+ return newIhdr;
986
+ });
987
+ const outChunks = [new Uint8Array(PNG_SIGNATURE), ...patchedPreData];
988
+ for (const chunk of frame.dataChunks) {
989
+ if (chunk.type === 'IDAT') {
990
+ outChunks.push(chunk.bytes);
991
+ }
992
+ else if (chunk.type === 'fdAT') {
993
+ const idatChunk = new Uint8Array(chunk.bytes.length - 4);
994
+ const idatView = new DataView(idatChunk.buffer);
995
+ idatView.setUint32(0, chunk.bytes.length - 16);
996
+ idatChunk.set([0x49, 0x44, 0x41, 0x54], 4);
997
+ idatChunk.set(chunk.bytes.subarray(12, chunk.bytes.length - 4), 8);
998
+ idatView.setUint32(idatChunk.length - 4, this.#crc32(idatChunk.subarray(4, idatChunk.length - 4)));
999
+ outChunks.push(idatChunk);
1000
+ }
1001
+ }
1002
+ outChunks.push(iend ?? IEND_BYTES);
1003
+ let totalSize = 0;
1004
+ for (const c of outChunks)
1005
+ totalSize += c.length;
1006
+ const pngBuffer = new Uint8Array(totalSize);
1007
+ let bufOffset = 0;
1008
+ for (const c of outChunks) {
1009
+ pngBuffer.set(c, bufOffset);
1010
+ bufOffset += c.length;
1011
+ }
1012
+ let binary = '';
1013
+ for (let j = 0; j < pngBuffer.length; j += 1) {
1014
+ binary += String.fromCharCode(pngBuffer[j]);
1015
+ }
1016
+ return btoa(binary);
1017
+ };
1018
+ /** @type {Map<number, string>} */
1019
+ const pngCache = new Map();
1020
+ /**
1021
+ * Returns the base64 PNG of a frame, encoding it only once.
1022
+ * @param {number} index - The zero-based frame index.
1023
+ * @returns {string} The base64-encoded PNG.
1024
+ */
1025
+ const getFramePng = (index) => {
1026
+ let png = pngCache.get(index);
1027
+ if (png === undefined) {
1028
+ png = buildFramePng(frames[index]);
1029
+ pngCache.set(index, png);
1030
+ }
1031
+ return png;
1032
+ };
1033
+ const canvasWidth = fullWidth || 1;
1034
+ const canvasHeight = fullHeight || 1;
1035
+ /** @type {string[]} */
1036
+ const defs = [];
1037
+ let maskSeq = 0;
1038
+ /**
1039
+ * Wraps `content` so the given rectangle becomes fully transparent.
1040
+ * @param {string} content - The SVG markup to erase from.
1041
+ * @param {number} x - Rectangle X offset.
1042
+ * @param {number} y - Rectangle Y offset.
1043
+ * @param {number} width - Rectangle width.
1044
+ * @param {number} height - Rectangle height.
1045
+ * @returns {string} The masked SVG markup.
1046
+ */
1047
+ const eraseRect = (content, x, y, width, height) => {
1048
+ if (content === '')
1049
+ return '';
1050
+ if (width >= canvasWidth && height >= canvasHeight)
1051
+ return '';
1052
+ if (width <= 0 || height <= 0)
1053
+ return content;
1054
+ const id = `apng-mask-${maskSeq}`;
1055
+ maskSeq += 1;
1056
+ defs.push(`<mask id="${id}" maskUnits="userSpaceOnUse" x="0" y="0" width="${canvasWidth}" height="${canvasHeight}">` +
1057
+ `<rect x="0" y="0" width="${canvasWidth}" height="${canvasHeight}" fill="#fff"/>` +
1058
+ `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="#000"/>` +
1059
+ `</mask>`);
1060
+ return `<g mask="url(#${id})">${content}</g>`;
1061
+ };
1062
+ let state = '';
1063
+ for (let i = 0; i < frames.length; i += 1) {
1064
+ const frame = frames[i];
1065
+ const stateBefore = state;
1066
+ const imageTag = `<image href="data:image/png;base64,${getFramePng(i)}"` +
1067
+ ` x="${frame.x}" y="${frame.y}" width="${frame.width}" height="${frame.height}"/>`;
1068
+ // blend_op: 0 = SOURCE (replace the rect), 1 = OVER (alpha composite).
1069
+ const drawn = frame.blendOp === 0
1070
+ ? eraseRect(state, frame.x, frame.y, frame.width, frame.height) + imageTag
1071
+ : state + imageTag;
1072
+ let delay = frame.delay;
1073
+ if (delay <= 10)
1074
+ delay = 100;
1075
+ this.#animationScript.push({ delay });
1076
+ if (this.#animationControl !== 'script-only') {
1077
+ let url = this.#getFrame(i);
1078
+ if (!url) {
1079
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${canvasWidth}" height="${canvasHeight}"` +
1080
+ ` viewBox="0 0 ${canvasWidth} ${canvasHeight}">` +
1081
+ (defs.length > 0 ? `<defs>${defs.join('')}</defs>` : '') +
1082
+ drawn +
1083
+ `</svg>`;
1084
+ url = this.#setFrame(i, new Blob([svg], { type: 'image/svg+xml' }));
1085
+ }
1086
+ this.#frames.push({ url, cacheKey: this.#frameKey(i) });
1087
+ }
1088
+ // dispose_op: 0 = NONE, 1 = BACKGROUND (erase), 2 = PREVIOUS (rollback).
1089
+ if (frame.disposeOp === 1) {
1090
+ state = eraseRect(drawn, frame.x, frame.y, frame.width, frame.height);
1091
+ }
1092
+ else if (frame.disposeOp === 2) {
1093
+ state = stateBefore;
1094
+ }
1095
+ else {
1096
+ state = drawn;
1097
+ }
1098
+ if (this.#animationControl === 'native')
1099
+ break;
1100
+ }
1101
+ }
1102
+ /**
1103
+ * Slices a GIF binary into multiple valid single-frame GIF blobs.
1104
+ * @param {Uint8Array<ArrayBuffer>} bytes - The full GIF file.
1105
+ * @returns {void}
1106
+ * @throws {TypeError} If `bytes` is not a Uint8Array.
1107
+ */
1108
+ #extractGifFrames(bytes) {
1109
+ if (!(bytes instanceof Uint8Array)) {
1110
+ throw new TypeError('The "bytes" argument must be a Uint8Array.');
1111
+ }
1112
+ if (bytes.length < 13)
1113
+ return;
1114
+ let offset = 13;
1115
+ const packed = bytes[10];
1116
+ const hasGCT = packed & 0x80;
1117
+ const gctSize = hasGCT ? 3 * (1 << ((packed & 0x07) + 1)) : 0;
1118
+ offset += gctSize;
1119
+ // Cabeçalho global + Paleta de Cores (usado de base para todos os frames)
1120
+ const globalHeader = bytes.subarray(0, offset);
1121
+ let gce = null;
1122
+ let delay = 100;
1123
+ while (offset < bytes.length) {
1124
+ const block = bytes[offset];
1125
+ if (block === 0x3b)
1126
+ break; // Trailer de fim do GIF
1127
+ if (block === 0x21) {
1128
+ const ext = bytes[offset + 1];
1129
+ if (ext === 0xf9) {
1130
+ // Graphic Control Extension (Possui o Delay)
1131
+ gce = bytes.subarray(offset, offset + 8);
1132
+ delay = (bytes[offset + 4] | (bytes[offset + 5] << 8)) * 10;
1133
+ if (delay <= 10)
1134
+ delay = 100; // Correção para delays muito rápidos
1135
+ }
1136
+ // Pular blocos de dados
1137
+ offset += 2;
1138
+ while (offset < bytes.length && bytes[offset] !== 0) {
1139
+ offset += bytes[offset] + 1;
1140
+ }
1141
+ offset++;
1142
+ }
1143
+ else if (block === 0x2c) {
1144
+ // Image Descriptor
1145
+ const imgStart = offset;
1146
+ offset += 10;
1147
+ const localPacked = bytes[offset - 1];
1148
+ if (localPacked & 0x80) {
1149
+ offset += 3 * (1 << ((localPacked & 0x07) + 1)); // LCT Local
1150
+ }
1151
+ offset++; // LZW Min Code
1152
+ while (offset < bytes.length && bytes[offset] !== 0) {
1153
+ offset += bytes[offset] + 1;
1154
+ }
1155
+ offset++;
1156
+ const imgEnd = offset;
1157
+ // Registrar o script de tempo
1158
+ this.#animationScript.push({ delay });
1159
+ if (this.#animationControl !== 'script-only') {
1160
+ const index = this.#frames.length;
1161
+ let url = this.#getFrame(index);
1162
+ if (!url) {
1163
+ const frameBytes = new Uint8Array(globalHeader.length + (gce ? gce.length : 0) + (imgEnd - imgStart) + 1);
1164
+ frameBytes.set(globalHeader, 0);
1165
+ let cursor = globalHeader.length;
1166
+ if (gce) {
1167
+ frameBytes.set(gce, cursor);
1168
+ cursor += gce.length;
1169
+ }
1170
+ frameBytes.set(bytes.subarray(imgStart, imgEnd), cursor);
1171
+ frameBytes[frameBytes.length - 1] = 0x3b;
1172
+ const blob = new Blob([frameBytes], { type: 'image/gif' });
1173
+ url = this.#setFrame(index, blob);
1174
+ }
1175
+ this.#frames.push({ url, cacheKey: this.#frameKey(index) });
1176
+ }
1177
+ // Em modo Nativo, nós só precisamos fatiar o Primeiro frame para a função de freeze
1178
+ if (this.#animationControl === 'native')
1179
+ break;
1180
+ gce = null;
1181
+ }
1182
+ else {
1183
+ break; // Bloco desconhecido, evita loops
1184
+ }
1185
+ }
1186
+ }
1187
+ /**
1188
+ * Executes the manual animation loop.
1189
+ * @returns {void}
1190
+ */
1191
+ #playLoop() {
1192
+ if (!this.#isPlaying || this.#isFrozen || !this.#image || this.#frames.length === 0) {
1193
+ return;
1194
+ }
1195
+ const frame = this.#frames[this.#currentFrameIndex];
1196
+ if (!frame) {
1197
+ return;
1198
+ }
1199
+ const scriptStep = this.#animationScript[this.#currentFrameIndex];
1200
+ const delay = (scriptStep?.delay || 100) / this.#playbackRate;
1201
+ this._setInternalSrc(frame.url);
1202
+ this.#image.src = frame.url;
1203
+ this._flushMutations();
1204
+ this.#playbackTimer = setTimeout(() => {
1205
+ this.#currentFrameIndex = (this.#currentFrameIndex + 1) % this.#frames.length;
1206
+ this.#playLoop();
1207
+ }, delay);
1208
+ }
1209
+ // --- Overrides ---
1210
+ /**
1211
+ * Builds the metadata descriptor for the loaded image.
1212
+ * @protected
1213
+ * @returns {import('../TinyMediaLoader.mjs').MediaMetadataDetails} The image metadata details.
1214
+ */
1215
+ _getMetadataDetails() {
1216
+ const image = this.#image;
1217
+ const animation = this.metadata.animation;
1218
+ return {
1219
+ type: image?.naturalWidth ? `image/${this.#getExtension()}` : 'image/*',
1220
+ width: image?.naturalWidth ?? 0,
1221
+ height: image?.naturalHeight ?? 0,
1222
+ duration: animation ? animation.duration / 1000 : NaN,
1223
+ size: this._getResourceSize(),
1224
+ };
1225
+ }
1226
+ /**
1227
+ * Aborts the fetch gracefully allowing a retry process to spin up safely on the same element instance without
1228
+ * triggering stale asynchronous cache responses.
1229
+ * @protected
1230
+ * @returns {void}
1231
+ */
1232
+ _abort() {
1233
+ super._abort();
1234
+ if (this.#image) {
1235
+ this.#image.src = EMPTY_GIF;
1236
+ this._flushMutations();
1237
+ }
1238
+ }
1239
+ /**
1240
+ * Releases all acquired animation frames from the cache or revokes their
1241
+ * object URLs. It also stops the playback loop, so a reload never leaves a
1242
+ * timer pointing at a frame that no longer exists.
1243
+ * @protected
1244
+ * @returns {void}
1245
+ */
1246
+ _releaseAnimationFrames() {
1247
+ if (this.#playbackTimer !== null) {
1248
+ clearTimeout(this.#playbackTimer);
1249
+ this.#playbackTimer = null;
1250
+ }
1251
+ for (const frame of this.#frames) {
1252
+ const cache = this.cache ?? this.#frameCache;
1253
+ if (cache) {
1254
+ cache.release(frame.cacheKey);
1255
+ }
1256
+ else {
1257
+ URL.revokeObjectURL(frame.url);
1258
+ }
1259
+ }
1260
+ this.#frames = [];
1261
+ this.#animationScript = [];
1262
+ this.#currentFrameIndex = 0;
1263
+ this.#isFrozen = false;
1264
+ this.#isPlaying = false;
1265
+ }
1266
+ /**
1267
+ * Detaches the load handlers, revokes the object URL and clears the source of the image element.
1268
+ * @protected
1269
+ * @returns {void} This method does not return a value.
1270
+ */
1271
+ _cleanup() {
1272
+ this._revokeObjectUrl();
1273
+ this.pause();
1274
+ this._releaseAnimationFrames();
1275
+ this.#originalAnimatedUrl = null;
1276
+ if (this.#image) {
1277
+ this.#image.onload = null;
1278
+ this.#image.onerror = null;
1279
+ this.#image.removeAttribute('src');
1280
+ this.#image.src = EMPTY_GIF;
1281
+ this._flushMutations();
1282
+ }
1283
+ }
1284
+ /**
1285
+ * Creates the `<img>` element used by the loader.
1286
+ * @protected
1287
+ * @returns {HTMLImageElement} A new, detached image element.
1288
+ */
1289
+ _createElement() {
1290
+ return document.createElement('img');
1291
+ }
1292
+ /**
1293
+ * Resolves the renderable URL for a blob that was shared by a mirrored
1294
+ * download. The leading loader already owns the shared cache entry, so the
1295
+ * mirror only mints a private object URL over the very same in-memory blob.
1296
+ * @param {Blob} blob - The blob produced by the shared download.
1297
+ * @returns {string} The URL the element must render.
1298
+ * @throws {TypeError} If `blob` is not a Blob.
1299
+ */
1300
+ #adoptSharedBlob(blob) {
1301
+ if (!(blob instanceof Blob)) {
1302
+ throw new TypeError('The "blob" argument must be a Blob.');
1303
+ }
1304
+ this._setResourceSize(blob.size);
1305
+ return this._createObjectUrl(blob);
1306
+ }
1307
+ /**
1308
+ * Extracts the lowercase file extension from the image source.
1309
+ * @returns {string} The file extension without a dot, or `'png'` when none is found.
1310
+ */
1311
+ #getExtension() {
1312
+ const clean = this.src.split('?')[0].split('#')[0];
1313
+ const dot = clean.lastIndexOf('.');
1314
+ return dot === -1 ? 'png' : clean.slice(dot + 1).toLowerCase();
1315
+ }
1316
+ }
1317
+ export default TinyImageLoader;