chart-preview 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -38,13 +38,17 @@ var NOTE_SPAN_WIDTH = 0.95;
38
38
  function isImageDecoderSupported() {
39
39
  return typeof ImageDecoder !== "undefined";
40
40
  }
41
+ function areAnimationsSupported() {
42
+ return isImageDecoderSupported();
43
+ }
41
44
  var AnimatedTexture = class {
42
45
  constructor(width, height) {
43
- this.decoder = null;
44
46
  this.frameIndex = 0;
45
47
  this.frameCount = 0;
46
48
  this.lastFrameTime = 0;
47
49
  this.frameDurations = [];
50
+ /** Pre-decoded frames stored as ImageBitmap for fast synchronous access */
51
+ this.frameCache = [];
48
52
  this.isAnimated = false;
49
53
  this.disposed = false;
50
54
  this.canvas = document.createElement("canvas");
@@ -57,11 +61,11 @@ var AnimatedTexture = class {
57
61
  /**
58
62
  * Creates an AnimatedTexture from a URL.
59
63
  * Uses ImageDecoder for animation if available, otherwise loads as static texture.
64
+ * Pre-decodes all frames during initialization for optimal render performance.
60
65
  */
61
66
  static async create(url) {
62
67
  if (!isImageDecoderSupported()) {
63
- const textureLoader = new THREE.TextureLoader();
64
- return textureLoader.loadAsync(url);
68
+ return loadStaticTexture(url);
65
69
  }
66
70
  try {
67
71
  const response = await fetch(url);
@@ -71,8 +75,7 @@ var AnimatedTexture = class {
71
75
  const contentType = response.headers.get("content-type") || "image/webp";
72
76
  const isSupported = await ImageDecoder.isTypeSupported(contentType);
73
77
  if (!isSupported) {
74
- const textureLoader = new THREE.TextureLoader();
75
- return textureLoader.loadAsync(url);
78
+ return loadStaticTexture(url);
76
79
  }
77
80
  const decoder = new ImageDecoder({
78
81
  data: response.body,
@@ -103,40 +106,51 @@ var AnimatedTexture = class {
103
106
  firstFrame.displayWidth,
104
107
  firstFrame.displayHeight
105
108
  );
106
- animTexture.ctx.drawImage(firstFrame, 0, 0);
107
- animTexture.texture.needsUpdate = true;
108
- animTexture.decoder = decoder;
109
- animTexture.frameCount = frameCount;
110
- animTexture.isAnimated = true;
111
109
  animTexture.frameDurations = [];
110
+ animTexture.frameCache = [];
112
111
  for (let i = 0; i < frameCount; i++) {
113
112
  try {
114
113
  const frameResult = await decoder.decode({ frameIndex: i });
115
- const durationMs = (frameResult.image.duration ?? 1e5) / 1e3;
114
+ const videoFrame = frameResult.image;
115
+ const bitmap = await createImageBitmap(videoFrame);
116
+ animTexture.frameCache.push(bitmap);
117
+ const durationMs = (videoFrame.duration ?? 1e5) / 1e3;
116
118
  animTexture.frameDurations.push(durationMs);
117
- frameResult.image.close();
119
+ videoFrame.close();
118
120
  } catch {
119
121
  animTexture.frameDurations.push(100);
122
+ if (animTexture.frameCache.length > 0) {
123
+ animTexture.frameCache.push(
124
+ animTexture.frameCache[animTexture.frameCache.length - 1]
125
+ );
126
+ }
120
127
  }
121
128
  }
122
- firstFrame.close();
129
+ if (animTexture.frameCache.length > 0) {
130
+ animTexture.ctx.drawImage(animTexture.frameCache[0], 0, 0);
131
+ animTexture.texture.needsUpdate = true;
132
+ }
133
+ animTexture.frameCount = animTexture.frameCache.length;
134
+ animTexture.isAnimated = animTexture.frameCount > 1;
123
135
  animTexture.lastFrameTime = performance.now();
136
+ firstFrame.close();
137
+ decoder.close();
124
138
  return animTexture;
125
139
  } catch (error) {
126
140
  console.warn(
127
141
  "Failed to load animated texture, falling back to static:",
128
142
  error
129
143
  );
130
- const textureLoader = new THREE.TextureLoader();
131
- return textureLoader.loadAsync(url);
144
+ return loadStaticTexture(url);
132
145
  }
133
146
  }
134
147
  /**
135
148
  * Updates the texture to the current animation frame based on elapsed time.
136
- * Call this each frame in the render loop.
149
+ * This method is SYNCHRONOUS for optimal render performance - all frames
150
+ * are pre-decoded during initialization.
137
151
  */
138
- async tick() {
139
- if (!this.isAnimated || !this.decoder || this.disposed) {
152
+ tick() {
153
+ if (!this.isAnimated || this.disposed || this.frameCache.length === 0) {
140
154
  return;
141
155
  }
142
156
  const now = performance.now();
@@ -145,15 +159,11 @@ var AnimatedTexture = class {
145
159
  if (elapsed >= currentFrameDuration) {
146
160
  this.frameIndex = (this.frameIndex + 1) % this.frameCount;
147
161
  this.lastFrameTime = now;
148
- try {
149
- const result = await this.decoder.decode({
150
- frameIndex: this.frameIndex
151
- });
162
+ const frame = this.frameCache[this.frameIndex];
163
+ if (frame) {
152
164
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
153
- this.ctx.drawImage(result.image, 0, 0);
165
+ this.ctx.drawImage(frame, 0, 0);
154
166
  this.texture.needsUpdate = true;
155
- result.image.close();
156
- } catch {
157
167
  }
158
168
  }
159
169
  }
@@ -162,13 +172,35 @@ var AnimatedTexture = class {
162
172
  */
163
173
  dispose() {
164
174
  this.disposed = true;
165
- if (this.decoder) {
166
- this.decoder.close();
167
- this.decoder = null;
175
+ for (const bitmap of this.frameCache) {
176
+ bitmap.close();
168
177
  }
178
+ this.frameCache = [];
169
179
  this.texture.dispose();
170
180
  }
171
181
  };
182
+ async function loadStaticTexture(url) {
183
+ const textureLoader = new THREE.TextureLoader();
184
+ try {
185
+ const texture = await textureLoader.loadAsync(url);
186
+ texture.colorSpace = THREE.SRGBColorSpace;
187
+ return texture;
188
+ } catch (error) {
189
+ console.warn(`Failed to load static texture from ${url}:`, error);
190
+ return createPlaceholderTexture();
191
+ }
192
+ }
193
+ function createPlaceholderTexture() {
194
+ const canvas = document.createElement("canvas");
195
+ canvas.width = 32;
196
+ canvas.height = 32;
197
+ const ctx = canvas.getContext("2d");
198
+ ctx.fillStyle = "#FF00FF";
199
+ ctx.fillRect(0, 0, 32, 32);
200
+ const texture = new THREE.CanvasTexture(canvas);
201
+ texture.colorSpace = THREE.SRGBColorSpace;
202
+ return texture;
203
+ }
172
204
  var AnimatedTextureManager = class {
173
205
  constructor() {
174
206
  this.animatedTextures = [];
@@ -178,8 +210,13 @@ var AnimatedTextureManager = class {
178
210
  this.animatedTextures.push(texture);
179
211
  }
180
212
  }
181
- async tick() {
182
- await Promise.all(this.animatedTextures.map((t) => t.tick()));
213
+ /**
214
+ * Updates all animated textures. This is now SYNCHRONOUS for optimal performance.
215
+ */
216
+ tick() {
217
+ for (const texture of this.animatedTextures) {
218
+ texture.tick();
219
+ }
183
220
  }
184
221
  dispose() {
185
222
  for (const texture of this.animatedTextures) {
@@ -782,11 +819,16 @@ var NotesManager = class {
782
819
  }
783
820
  for (let i = maxNoteEventIndex + 1; this.noteEvents[i] && this.noteEvents[i].msTime < renderEndTimeMs; i++) {
784
821
  const note = this.noteEvents[i];
822
+ const material = this.noteMaterials.get(note.type)?.get(note.flags);
823
+ if (!material) {
824
+ console.warn(
825
+ `Missing texture for note type ${note.type} with flags ${note.flags}`
826
+ );
827
+ continue;
828
+ }
785
829
  const noteGroup = new THREE.Group();
786
830
  const scale = note.type === noteTypes.kick ? 0.045 : note.type === noteTypes.open && this.instrumentType === instrumentTypes.sixFret ? 0.04 : SCALE;
787
- const sprite = new THREE.Sprite(
788
- this.noteMaterials.get(note.type).get(note.flags)
789
- );
831
+ const sprite = new THREE.Sprite(material);
790
832
  noteGroup.add(sprite);
791
833
  sprite.center = new THREE.Vector2(
792
834
  note.type === noteTypes.kick ? 0.62 : 0.5,
@@ -863,12 +905,16 @@ var EventSequence = class {
863
905
  return earliestActiveEventIndex === null ? this.lastPrecedingEventIndex + 1 : earliestActiveEventIndex;
864
906
  }
865
907
  };
866
- async function loadTextures(instrumentType) {
908
+ async function loadTextures(instrumentType, options = {}) {
909
+ const { animationsEnabled = true } = options;
867
910
  const textureLoader = new THREE.TextureLoader();
868
911
  const animatedTextureManager = new AnimatedTextureManager();
869
912
  const load = (path) => textureLoader.loadAsync("https://static.enchor.us/" + path);
870
913
  const loadAnimated = async (path) => {
871
914
  const url = `https://static.enchor.us/preview-${path}.webp`;
915
+ if (!animationsEnabled) {
916
+ return loadStaticTexture(url);
917
+ }
872
918
  const result = await AnimatedTexture.create(url);
873
919
  if (result instanceof AnimatedTexture) {
874
920
  animatedTextureManager.register(result);
@@ -1314,7 +1360,7 @@ function isVideoFile(fileName) {
1314
1360
  const ext = fileName.toLowerCase().slice(fileName.lastIndexOf("."));
1315
1361
  return videoExtensions.includes(ext);
1316
1362
  }
1317
- async function prepareChartData(files, instrument, difficulty, initialSeekPercent = 0) {
1363
+ async function prepareChartData(files, instrument, difficulty, initialSeekPercent = 0, textureOptions = {}) {
1318
1364
  const filteredFiles = files.filter((f) => !isVideoFile(f.fileName));
1319
1365
  const scannedChart = scanChartFolder(filteredFiles);
1320
1366
  if (!scannedChart.playable || !scannedChart.notesData) {
@@ -1349,7 +1395,10 @@ async function prepareChartData(files, instrument, difficulty, initialSeekPercen
1349
1395
  }
1350
1396
  const audioFiles = findAudioFiles(filteredFiles);
1351
1397
  const instrumentType = getInstrumentType2(instrument);
1352
- const textures = await ChartPreview.loadTextures(instrumentType);
1398
+ const textures = await ChartPreview.loadTextures(
1399
+ instrumentType,
1400
+ textureOptions
1401
+ );
1353
1402
  const lastNote = parsedChart.trackData.flatMap((t) => t.noteEventGroups.flat()).reduce((max, note) => Math.max(max, note.msTime + note.msLength), 0);
1354
1403
  const audioLengthMs = scannedChart.song_length || lastNote + 5e3;
1355
1404
  const startDelayMs = (scannedChart.delay || 0) + (scannedChart.chart_offset || 0) * 1e3;
@@ -1670,10 +1719,11 @@ var ChartPreviewPlayer = class extends HTMLElement {
1670
1719
  }
1671
1720
  };
1672
1721
  this.handleFullscreenChange = () => {
1673
- this._isFullscreen = document.fullscreenElement === this.container;
1722
+ const fullscreenElement = document.fullscreenElement || document.webkitFullscreenElement;
1723
+ this._isFullscreen = fullscreenElement !== null && fullscreenElement !== void 0;
1674
1724
  this.container.classList.toggle("fullscreen", this._isFullscreen);
1675
1725
  this.fullscreenBtn.innerHTML = this._isFullscreen ? ICONS.fullscreenExit : ICONS.fullscreen;
1676
- this.fullscreenBtn.title = this._isFullscreen ? "Exit fullscreen" : "Toggle fullscreen";
1726
+ this.fullscreenBtn.title = this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
1677
1727
  };
1678
1728
  this.shadow = this.attachShadow({ mode: "open" });
1679
1729
  this.render();
@@ -1698,6 +1748,10 @@ var ChartPreviewPlayer = class extends HTMLElement {
1698
1748
  connectedCallback() {
1699
1749
  this.setAttribute("tabindex", "0");
1700
1750
  document.addEventListener("fullscreenchange", this.handleFullscreenChange);
1751
+ document.addEventListener(
1752
+ "webkitfullscreenchange",
1753
+ this.handleFullscreenChange
1754
+ );
1701
1755
  }
1702
1756
  disconnectedCallback() {
1703
1757
  this.dispose();
@@ -1705,6 +1759,10 @@ var ChartPreviewPlayer = class extends HTMLElement {
1705
1759
  "fullscreenchange",
1706
1760
  this.handleFullscreenChange
1707
1761
  );
1762
+ document.removeEventListener(
1763
+ "webkitfullscreenchange",
1764
+ this.handleFullscreenChange
1765
+ );
1708
1766
  this.removeEventListener("keydown", this.handleKeydown);
1709
1767
  }
1710
1768
  render() {
@@ -1785,7 +1843,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
1785
1843
  this.onSeekEnd();
1786
1844
  }
1787
1845
  });
1788
- this.fullscreenBtn.addEventListener("click", () => {
1846
+ this.fullscreenBtn.addEventListener("click", (e) => {
1847
+ e.stopPropagation();
1789
1848
  this.toggleFullscreen();
1790
1849
  });
1791
1850
  this.addEventListener("keydown", this.handleKeydown);
@@ -2098,7 +2157,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
2098
2157
  files,
2099
2158
  config.instrument,
2100
2159
  config.difficulty,
2101
- config.initialSeekPercent
2160
+ config.initialSeekPercent,
2161
+ { animationsEnabled: config.animationsEnabled }
2102
2162
  );
2103
2163
  await this.loadChart({
2104
2164
  parsedChart: preparedData.parsedChart,
@@ -2140,7 +2200,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
2140
2200
  files,
2141
2201
  config.instrument,
2142
2202
  config.difficulty,
2143
- config.initialSeekPercent
2203
+ config.initialSeekPercent,
2204
+ { animationsEnabled: config.animationsEnabled }
2144
2205
  );
2145
2206
  await this.loadChart({
2146
2207
  parsedChart: preparedData.parsedChart,
@@ -2179,7 +2240,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
2179
2240
  config.files,
2180
2241
  config.instrument,
2181
2242
  config.difficulty,
2182
- config.initialSeekPercent
2243
+ config.initialSeekPercent,
2244
+ { animationsEnabled: config.animationsEnabled }
2183
2245
  );
2184
2246
  await this.loadChart({
2185
2247
  parsedChart: preparedData.parsedChart,
@@ -2279,12 +2341,28 @@ var ChartPreviewPlayer = class extends HTMLElement {
2279
2341
  * Toggle fullscreen mode
2280
2342
  */
2281
2343
  async toggleFullscreen() {
2282
- if (!document.fullscreenEnabled)
2344
+ const doc = document;
2345
+ const container = this.container;
2346
+ const isFullscreenEnabled = doc.fullscreenEnabled || doc.webkitFullscreenEnabled;
2347
+ if (!isFullscreenEnabled)
2283
2348
  return;
2284
- if (this._isFullscreen) {
2285
- await document.exitFullscreen();
2286
- } else {
2287
- await this.container.requestFullscreen();
2349
+ const fullscreenElement = doc.fullscreenElement || doc.webkitFullscreenElement;
2350
+ try {
2351
+ if (fullscreenElement) {
2352
+ if (doc.exitFullscreen) {
2353
+ await doc.exitFullscreen();
2354
+ } else if (doc.webkitExitFullscreen) {
2355
+ doc.webkitExitFullscreen();
2356
+ }
2357
+ } else {
2358
+ if (container.requestFullscreen) {
2359
+ await container.requestFullscreen();
2360
+ } else if (container.webkitRequestFullscreen) {
2361
+ container.webkitRequestFullscreen();
2362
+ }
2363
+ }
2364
+ } catch (error) {
2365
+ console.warn("Fullscreen toggle failed:", error);
2288
2366
  }
2289
2367
  }
2290
2368
  /**
@@ -2319,6 +2397,7 @@ import {
2319
2397
  export {
2320
2398
  ChartPreview,
2321
2399
  ChartPreviewPlayer,
2400
+ areAnimationsSupported,
2322
2401
  extractSngFile,
2323
2402
  fetchSngFile,
2324
2403
  findAudioFiles,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chart-preview",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "author": "Geo",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -33,19 +33,29 @@ function isImageDecoderSupported(): boolean {
33
33
  return typeof ImageDecoder !== "undefined";
34
34
  }
35
35
 
36
+ /**
37
+ * Check if animations are supported on this browser.
38
+ * Use this to conditionally enable/disable animation features.
39
+ */
40
+ export function areAnimationsSupported(): boolean {
41
+ return isImageDecoderSupported();
42
+ }
43
+
36
44
  /**
37
45
  * Manages an animated WebP texture using the ImageDecoder API.
46
+ * Pre-decodes all frames during initialization for optimal performance.
38
47
  * Falls back to a static texture if ImageDecoder is not supported.
39
48
  */
40
49
  class AnimatedTexture {
41
50
  private canvas: HTMLCanvasElement;
42
51
  private ctx: CanvasRenderingContext2D;
43
52
  public texture: THREE.CanvasTexture;
44
- private decoder: ImageDecoder | null = null;
45
53
  private frameIndex = 0;
46
54
  private frameCount = 0;
47
55
  private lastFrameTime = 0;
48
56
  private frameDurations: number[] = [];
57
+ /** Pre-decoded frames stored as ImageBitmap for fast synchronous access */
58
+ private frameCache: ImageBitmap[] = [];
49
59
  private isAnimated = false;
50
60
  private disposed = false;
51
61
 
@@ -61,12 +71,12 @@ class AnimatedTexture {
61
71
  /**
62
72
  * Creates an AnimatedTexture from a URL.
63
73
  * Uses ImageDecoder for animation if available, otherwise loads as static texture.
74
+ * Pre-decodes all frames during initialization for optimal render performance.
64
75
  */
65
76
  static async create(url: string): Promise<AnimatedTexture | THREE.Texture> {
66
77
  if (!isImageDecoderSupported()) {
67
78
  // Fall back to static texture loading
68
- const textureLoader = new THREE.TextureLoader();
69
- return textureLoader.loadAsync(url);
79
+ return loadStaticTexture(url);
70
80
  }
71
81
 
72
82
  try {
@@ -80,8 +90,7 @@ class AnimatedTexture {
80
90
  const isSupported = await ImageDecoder.isTypeSupported(contentType);
81
91
  if (!isSupported) {
82
92
  // Fall back to static texture
83
- const textureLoader = new THREE.TextureLoader();
84
- return textureLoader.loadAsync(url);
93
+ return loadStaticTexture(url);
85
94
  }
86
95
 
87
96
  const decoder = new ImageDecoder({
@@ -113,37 +122,56 @@ class AnimatedTexture {
113
122
  return animTexture.texture;
114
123
  }
115
124
 
116
- // Animated image - decode first frame and set up animation
125
+ // Animated image - pre-decode ALL frames for optimal performance
117
126
  const firstResult = await decoder.decode({ frameIndex: 0 });
118
127
  const firstFrame = firstResult.image;
119
128
  const animTexture = new AnimatedTexture(
120
129
  firstFrame.displayWidth,
121
130
  firstFrame.displayHeight
122
131
  );
123
- animTexture.ctx.drawImage(firstFrame, 0, 0);
124
- animTexture.texture.needsUpdate = true;
125
-
126
- animTexture.decoder = decoder;
127
- animTexture.frameCount = frameCount;
128
- animTexture.isAnimated = true;
129
132
 
130
- // Pre-decode frame durations
133
+ // Pre-decode all frames into ImageBitmap cache
131
134
  animTexture.frameDurations = [];
135
+ animTexture.frameCache = [];
136
+
132
137
  for (let i = 0; i < frameCount; i++) {
133
138
  try {
134
139
  const frameResult = await decoder.decode({ frameIndex: i });
140
+ const videoFrame = frameResult.image;
141
+
142
+ // Create an ImageBitmap from the VideoFrame for fast synchronous access
143
+ const bitmap = await createImageBitmap(videoFrame);
144
+ animTexture.frameCache.push(bitmap);
145
+
135
146
  // Duration is in microseconds, convert to milliseconds
136
- const durationMs = (frameResult.image.duration ?? 100000) / 1000;
147
+ const durationMs = (videoFrame.duration ?? 100000) / 1000;
137
148
  animTexture.frameDurations.push(durationMs);
138
- frameResult.image.close();
149
+ videoFrame.close();
139
150
  } catch {
140
151
  animTexture.frameDurations.push(100); // Default 100ms
152
+ // If frame decode fails, reuse the last successful frame or create empty bitmap
153
+ if (animTexture.frameCache.length > 0) {
154
+ animTexture.frameCache.push(
155
+ animTexture.frameCache[animTexture.frameCache.length - 1]
156
+ );
157
+ }
141
158
  }
142
159
  }
143
160
 
144
- firstFrame.close();
161
+ // Draw the first frame
162
+ if (animTexture.frameCache.length > 0) {
163
+ animTexture.ctx.drawImage(animTexture.frameCache[0], 0, 0);
164
+ animTexture.texture.needsUpdate = true;
165
+ }
166
+
167
+ animTexture.frameCount = animTexture.frameCache.length;
168
+ animTexture.isAnimated = animTexture.frameCount > 1;
145
169
  animTexture.lastFrameTime = performance.now();
146
170
 
171
+ // Close the decoder - we no longer need it since all frames are cached
172
+ firstFrame.close();
173
+ decoder.close();
174
+
147
175
  return animTexture;
148
176
  } catch (error) {
149
177
  // Fall back to static texture on any error
@@ -151,17 +179,17 @@ class AnimatedTexture {
151
179
  "Failed to load animated texture, falling back to static:",
152
180
  error
153
181
  );
154
- const textureLoader = new THREE.TextureLoader();
155
- return textureLoader.loadAsync(url);
182
+ return loadStaticTexture(url);
156
183
  }
157
184
  }
158
185
 
159
186
  /**
160
187
  * Updates the texture to the current animation frame based on elapsed time.
161
- * Call this each frame in the render loop.
188
+ * This method is SYNCHRONOUS for optimal render performance - all frames
189
+ * are pre-decoded during initialization.
162
190
  */
163
- async tick(): Promise<void> {
164
- if (!this.isAnimated || !this.decoder || this.disposed) {
191
+ tick(): void {
192
+ if (!this.isAnimated || this.disposed || this.frameCache.length === 0) {
165
193
  return;
166
194
  }
167
195
 
@@ -173,16 +201,12 @@ class AnimatedTexture {
173
201
  this.frameIndex = (this.frameIndex + 1) % this.frameCount;
174
202
  this.lastFrameTime = now;
175
203
 
176
- try {
177
- const result = await this.decoder.decode({
178
- frameIndex: this.frameIndex,
179
- });
204
+ // Synchronous frame update from pre-decoded cache
205
+ const frame = this.frameCache[this.frameIndex];
206
+ if (frame) {
180
207
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
181
- this.ctx.drawImage(result.image, 0, 0);
208
+ this.ctx.drawImage(frame, 0, 0);
182
209
  this.texture.needsUpdate = true;
183
- result.image.close();
184
- } catch {
185
- // Frame decode failed, skip this frame
186
210
  }
187
211
  }
188
212
  }
@@ -192,14 +216,49 @@ class AnimatedTexture {
192
216
  */
193
217
  dispose(): void {
194
218
  this.disposed = true;
195
- if (this.decoder) {
196
- this.decoder.close();
197
- this.decoder = null;
219
+ // Close all cached ImageBitmaps
220
+ for (const bitmap of this.frameCache) {
221
+ bitmap.close();
198
222
  }
223
+ this.frameCache = [];
199
224
  this.texture.dispose();
200
225
  }
201
226
  }
202
227
 
228
+ /**
229
+ * Loads a static texture from a URL with proper error handling.
230
+ * Creates a fallback placeholder texture if loading fails.
231
+ */
232
+ async function loadStaticTexture(url: string): Promise<THREE.Texture> {
233
+ const textureLoader = new THREE.TextureLoader();
234
+ try {
235
+ const texture = await textureLoader.loadAsync(url);
236
+ texture.colorSpace = THREE.SRGBColorSpace;
237
+ return texture;
238
+ } catch (error) {
239
+ console.warn(`Failed to load static texture from ${url}:`, error);
240
+ // Create a small placeholder texture so notes still render
241
+ return createPlaceholderTexture();
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Creates a placeholder texture (magenta square) for missing textures.
247
+ * This ensures notes always render even if texture loading fails.
248
+ */
249
+ function createPlaceholderTexture(): THREE.Texture {
250
+ const canvas = document.createElement("canvas");
251
+ canvas.width = 32;
252
+ canvas.height = 32;
253
+ const ctx = canvas.getContext("2d")!;
254
+ // Draw a magenta square to make missing textures obvious
255
+ ctx.fillStyle = "#FF00FF";
256
+ ctx.fillRect(0, 0, 32, 32);
257
+ const texture = new THREE.CanvasTexture(canvas);
258
+ texture.colorSpace = THREE.SRGBColorSpace;
259
+ return texture;
260
+ }
261
+
203
262
  /** Collection of animated textures that need to be ticked each frame */
204
263
  class AnimatedTextureManager {
205
264
  private animatedTextures: AnimatedTexture[] = [];
@@ -210,8 +269,13 @@ class AnimatedTextureManager {
210
269
  }
211
270
  }
212
271
 
213
- async tick(): Promise<void> {
214
- await Promise.all(this.animatedTextures.map((t) => t.tick()));
272
+ /**
273
+ * Updates all animated textures. This is now SYNCHRONOUS for optimal performance.
274
+ */
275
+ tick(): void {
276
+ for (const texture of this.animatedTextures) {
277
+ texture.tick();
278
+ }
215
279
  }
216
280
 
217
281
  dispose(): void {
@@ -1062,6 +1126,17 @@ class NotesManager {
1062
1126
  i++
1063
1127
  ) {
1064
1128
  const note = this.noteEvents[i];
1129
+
1130
+ // Get the material for this note type and flags
1131
+ const material = this.noteMaterials.get(note.type)?.get(note.flags);
1132
+ if (!material) {
1133
+ // Skip notes with missing textures (shouldn't happen, but prevents crashes)
1134
+ console.warn(
1135
+ `Missing texture for note type ${note.type} with flags ${note.flags}`
1136
+ );
1137
+ continue;
1138
+ }
1139
+
1065
1140
  const noteGroup = new THREE.Group();
1066
1141
  const scale =
1067
1142
  note.type === noteTypes.kick
@@ -1070,9 +1145,7 @@ class NotesManager {
1070
1145
  this.instrumentType === instrumentTypes.sixFret
1071
1146
  ? 0.04
1072
1147
  : SCALE;
1073
- const sprite = new THREE.Sprite(
1074
- this.noteMaterials.get(note.type)!.get(note.flags)!
1075
- );
1148
+ const sprite = new THREE.Sprite(material);
1076
1149
  noteGroup.add(sprite);
1077
1150
  sprite.center = new THREE.Vector2(
1078
1151
  note.type === noteTypes.kick ? 0.62 : 0.5,
@@ -1177,12 +1250,27 @@ class EventSequence<
1177
1250
  }
1178
1251
  }
1179
1252
 
1253
+ export interface LoadTexturesOptions {
1254
+ /**
1255
+ * Whether to enable animated textures.
1256
+ * When true (default), uses ImageDecoder API for animated WebP if available.
1257
+ * When false, always loads static textures for better performance.
1258
+ * Use `areAnimationsSupported()` to check if animations are supported on the current browser.
1259
+ */
1260
+ animationsEnabled?: boolean;
1261
+ }
1262
+
1180
1263
  /**
1181
1264
  * Loads all required textures for the chart preview.
1182
1265
  * @param instrumentType The type of instrument to load textures for
1266
+ * @param options Optional configuration for texture loading
1183
1267
  * @returns An object containing highway, strikeline, note textures, and animated texture manager
1184
1268
  */
1185
- async function loadTextures(instrumentType: InstrumentType) {
1269
+ async function loadTextures(
1270
+ instrumentType: InstrumentType,
1271
+ options: LoadTexturesOptions = {}
1272
+ ) {
1273
+ const { animationsEnabled = true } = options;
1186
1274
  const textureLoader = new THREE.TextureLoader();
1187
1275
  const animatedTextureManager = new AnimatedTextureManager();
1188
1276
 
@@ -1192,9 +1280,16 @@ async function loadTextures(instrumentType: InstrumentType) {
1192
1280
  /**
1193
1281
  * Loads a texture with animation support.
1194
1282
  * Uses ImageDecoder API for animated WebP when available, falls back to static.
1283
+ * If animationsEnabled is false, always loads static textures.
1195
1284
  */
1196
1285
  const loadAnimated = async (path: string): Promise<THREE.Texture> => {
1197
1286
  const url = `https://static.enchor.us/preview-${path}.webp`;
1287
+
1288
+ // If animations are disabled, load static texture directly
1289
+ if (!animationsEnabled) {
1290
+ return loadStaticTexture(url);
1291
+ }
1292
+
1198
1293
  const result = await AnimatedTexture.create(url);
1199
1294
 
1200
1295
  if (result instanceof AnimatedTexture) {