chart-preview 1.0.3 → 1.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # chart-preview
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 885a2db: Add note animations (Chrome only)
8
+
3
9
  ## 1.0.3
4
10
 
5
11
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -3,6 +3,44 @@ export { Difficulty, Instrument, InstrumentType, getInstrumentType, instrumentTy
3
3
  import * as THREE from 'three';
4
4
 
5
5
  type ParsedChart = ReturnType<typeof parseChartFile>;
6
+ /**
7
+ * Manages an animated WebP texture using the ImageDecoder API.
8
+ * Falls back to a static texture if ImageDecoder is not supported.
9
+ */
10
+ declare class AnimatedTexture {
11
+ private canvas;
12
+ private ctx;
13
+ texture: THREE.CanvasTexture;
14
+ private decoder;
15
+ private frameIndex;
16
+ private frameCount;
17
+ private lastFrameTime;
18
+ private frameDurations;
19
+ private isAnimated;
20
+ private disposed;
21
+ private constructor();
22
+ /**
23
+ * Creates an AnimatedTexture from a URL.
24
+ * Uses ImageDecoder for animation if available, otherwise loads as static texture.
25
+ */
26
+ static create(url: string): Promise<AnimatedTexture | THREE.Texture>;
27
+ /**
28
+ * Updates the texture to the current animation frame based on elapsed time.
29
+ * Call this each frame in the render loop.
30
+ */
31
+ tick(): Promise<void>;
32
+ /**
33
+ * Disposes of resources used by this animated texture.
34
+ */
35
+ dispose(): void;
36
+ }
37
+ /** Collection of animated textures that need to be ticked each frame */
38
+ declare class AnimatedTextureManager {
39
+ private animatedTextures;
40
+ register(texture: AnimatedTexture | THREE.Texture): void;
41
+ tick(): Promise<void>;
42
+ dispose(): void;
43
+ }
6
44
  /**
7
45
  * Custom note types for 6-fret barre notes.
8
46
  * These are the only sprites without a dedicated NoteType from scan-chart.
@@ -51,6 +89,7 @@ declare class ChartPreview {
51
89
  private renderer;
52
90
  private audioManager;
53
91
  private notesManager;
92
+ private animatedTextureManager;
54
93
  private progressIntervalMs;
55
94
  private lastProgressEmitTime;
56
95
  /**
@@ -141,12 +180,13 @@ declare class ChartPreview {
141
180
  /**
142
181
  * Loads all required textures for the chart preview.
143
182
  * @param instrumentType The type of instrument to load textures for
144
- * @returns An object containing highway, strikeline, and note textures
183
+ * @returns An object containing highway, strikeline, note textures, and animated texture manager
145
184
  */
146
185
  declare function loadTextures(instrumentType: InstrumentType): Promise<{
147
186
  highwayTexture: THREE.Texture;
148
187
  strikelineTexture: THREE.Texture;
149
188
  noteTextures: Map<ExtendedNoteType, Map<number, THREE.Texture>>;
189
+ animatedTextureManager: AnimatedTextureManager;
150
190
  }>;
151
191
 
152
192
  /**
package/dist/index.js CHANGED
@@ -74,6 +74,159 @@ function isAudioContextSupported() {
74
74
  var HIGHWAY_DURATION_MS = 1500;
75
75
  var SCALE = 0.105;
76
76
  var NOTE_SPAN_WIDTH = 0.95;
77
+ function isImageDecoderSupported() {
78
+ return typeof ImageDecoder !== "undefined";
79
+ }
80
+ var AnimatedTexture = class {
81
+ constructor(width, height) {
82
+ this.decoder = null;
83
+ this.frameIndex = 0;
84
+ this.frameCount = 0;
85
+ this.lastFrameTime = 0;
86
+ this.frameDurations = [];
87
+ this.isAnimated = false;
88
+ this.disposed = false;
89
+ this.canvas = document.createElement("canvas");
90
+ this.canvas.width = width;
91
+ this.canvas.height = height;
92
+ this.ctx = this.canvas.getContext("2d");
93
+ this.texture = new THREE.CanvasTexture(this.canvas);
94
+ this.texture.colorSpace = THREE.SRGBColorSpace;
95
+ }
96
+ /**
97
+ * Creates an AnimatedTexture from a URL.
98
+ * Uses ImageDecoder for animation if available, otherwise loads as static texture.
99
+ */
100
+ static async create(url) {
101
+ if (!isImageDecoderSupported()) {
102
+ const textureLoader = new THREE.TextureLoader();
103
+ return textureLoader.loadAsync(url);
104
+ }
105
+ try {
106
+ const response = await fetch(url);
107
+ if (!response.ok || !response.body) {
108
+ throw new Error(`Failed to fetch ${url}`);
109
+ }
110
+ const contentType = response.headers.get("content-type") || "image/webp";
111
+ const isSupported = await ImageDecoder.isTypeSupported(contentType);
112
+ if (!isSupported) {
113
+ const textureLoader = new THREE.TextureLoader();
114
+ return textureLoader.loadAsync(url);
115
+ }
116
+ const decoder = new ImageDecoder({
117
+ data: response.body,
118
+ type: contentType
119
+ });
120
+ await decoder.completed;
121
+ const track = decoder.tracks.selectedTrack;
122
+ if (!track) {
123
+ throw new Error("No track found in image");
124
+ }
125
+ const frameCount = track.frameCount;
126
+ if (frameCount <= 1) {
127
+ const result = await decoder.decode({ frameIndex: 0 });
128
+ const frame = result.image;
129
+ const animTexture2 = new AnimatedTexture(
130
+ frame.displayWidth,
131
+ frame.displayHeight
132
+ );
133
+ animTexture2.ctx.drawImage(frame, 0, 0);
134
+ animTexture2.texture.needsUpdate = true;
135
+ frame.close();
136
+ decoder.close();
137
+ return animTexture2.texture;
138
+ }
139
+ const firstResult = await decoder.decode({ frameIndex: 0 });
140
+ const firstFrame = firstResult.image;
141
+ const animTexture = new AnimatedTexture(
142
+ firstFrame.displayWidth,
143
+ firstFrame.displayHeight
144
+ );
145
+ animTexture.ctx.drawImage(firstFrame, 0, 0);
146
+ animTexture.texture.needsUpdate = true;
147
+ animTexture.decoder = decoder;
148
+ animTexture.frameCount = frameCount;
149
+ animTexture.isAnimated = true;
150
+ animTexture.frameDurations = [];
151
+ for (let i = 0; i < frameCount; i++) {
152
+ try {
153
+ const frameResult = await decoder.decode({ frameIndex: i });
154
+ const durationMs = (frameResult.image.duration ?? 1e5) / 1e3;
155
+ animTexture.frameDurations.push(durationMs);
156
+ frameResult.image.close();
157
+ } catch {
158
+ animTexture.frameDurations.push(100);
159
+ }
160
+ }
161
+ firstFrame.close();
162
+ animTexture.lastFrameTime = performance.now();
163
+ return animTexture;
164
+ } catch (error) {
165
+ console.warn(
166
+ "Failed to load animated texture, falling back to static:",
167
+ error
168
+ );
169
+ const textureLoader = new THREE.TextureLoader();
170
+ return textureLoader.loadAsync(url);
171
+ }
172
+ }
173
+ /**
174
+ * Updates the texture to the current animation frame based on elapsed time.
175
+ * Call this each frame in the render loop.
176
+ */
177
+ async tick() {
178
+ if (!this.isAnimated || !this.decoder || this.disposed) {
179
+ return;
180
+ }
181
+ const now = performance.now();
182
+ const elapsed = now - this.lastFrameTime;
183
+ const currentFrameDuration = this.frameDurations[this.frameIndex] || 100;
184
+ if (elapsed >= currentFrameDuration) {
185
+ this.frameIndex = (this.frameIndex + 1) % this.frameCount;
186
+ this.lastFrameTime = now;
187
+ try {
188
+ const result = await this.decoder.decode({
189
+ frameIndex: this.frameIndex
190
+ });
191
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
192
+ this.ctx.drawImage(result.image, 0, 0);
193
+ this.texture.needsUpdate = true;
194
+ result.image.close();
195
+ } catch {
196
+ }
197
+ }
198
+ }
199
+ /**
200
+ * Disposes of resources used by this animated texture.
201
+ */
202
+ dispose() {
203
+ this.disposed = true;
204
+ if (this.decoder) {
205
+ this.decoder.close();
206
+ this.decoder = null;
207
+ }
208
+ this.texture.dispose();
209
+ }
210
+ };
211
+ var AnimatedTextureManager = class {
212
+ constructor() {
213
+ this.animatedTextures = [];
214
+ }
215
+ register(texture) {
216
+ if (texture instanceof AnimatedTexture) {
217
+ this.animatedTextures.push(texture);
218
+ }
219
+ }
220
+ async tick() {
221
+ await Promise.all(this.animatedTextures.map((t) => t.tick()));
222
+ }
223
+ dispose() {
224
+ for (const texture of this.animatedTextures) {
225
+ texture.dispose();
226
+ }
227
+ this.animatedTextures = [];
228
+ }
229
+ };
77
230
  var BARRE1_TYPE = 99991;
78
231
  var BARRE2_TYPE = 99992;
79
232
  var BARRE3_TYPE = 99993;
@@ -145,6 +298,7 @@ var _ChartPreview = class {
145
298
  const chartPreview = new _ChartPreview();
146
299
  chartPreview.instrumentType = (0, import_scan_chart.getInstrumentType)(config.instrument);
147
300
  chartPreview.highwayTexture = config.textures.highwayTexture;
301
+ chartPreview.animatedTextureManager = config.textures.animatedTextureManager;
148
302
  chartPreview.progressIntervalMs = config.progressIntervalMs ?? DEFAULT_PROGRESS_INTERVAL_MS;
149
303
  chartPreview.camera = new ChartCamera(config.container);
150
304
  chartPreview.renderer = new ChartRenderer(config.container);
@@ -229,6 +383,7 @@ var _ChartPreview = class {
229
383
  this.renderer.dispose();
230
384
  this.renderer.forceContextLoss();
231
385
  this.audioManager.closeAudio();
386
+ this.animatedTextureManager.dispose();
232
387
  }
233
388
  addHighwayToScene(highwayTexture) {
234
389
  const mat = new THREE.MeshBasicMaterial({ map: highwayTexture });
@@ -264,6 +419,7 @@ var _ChartPreview = class {
264
419
  this.notesManager.updateDisplayedNotes(
265
420
  this.audioManager.chartCurrentTimeMs
266
421
  );
422
+ this.animatedTextureManager.tick();
267
423
  const scrollPosition = -0.9 * (this.audioManager.chartCurrentTimeMs / 1e3) * (HIGHWAY_DURATION_MS / 1e3);
268
424
  this.highwayTexture.offset.y = -1 * scrollPosition;
269
425
  this.renderer.render(this.scene, this.camera);
@@ -748,7 +904,17 @@ var EventSequence = class {
748
904
  };
749
905
  async function loadTextures(instrumentType) {
750
906
  const textureLoader = new THREE.TextureLoader();
907
+ const animatedTextureManager = new AnimatedTextureManager();
751
908
  const load = (path) => textureLoader.loadAsync("https://static.enchor.us/" + path);
909
+ const loadAnimated = async (path) => {
910
+ const url = `https://static.enchor.us/preview-${path}.webp`;
911
+ const result = await AnimatedTexture.create(url);
912
+ if (result instanceof AnimatedTexture) {
913
+ animatedTextureManager.register(result);
914
+ return result.texture;
915
+ }
916
+ return result;
917
+ };
752
918
  const [highwayTexture, strikelineTexture, noteTextures] = await Promise.all([
753
919
  (async () => {
754
920
  const texture = await load("preview-highway.png");
@@ -770,9 +936,7 @@ async function loadTextures(instrumentType) {
770
936
  (async () => {
771
937
  const texturePromises = [];
772
938
  const addTexture = (type, flags, path) => {
773
- const texture = textureLoader.loadAsync(
774
- `https://static.enchor.us/preview-${path}.webp`
775
- );
939
+ const texture = loadAnimated(path);
776
940
  texturePromises.push({ type, flags, texture });
777
941
  return texture;
778
942
  };
@@ -937,7 +1101,8 @@ async function loadTextures(instrumentType) {
937
1101
  return {
938
1102
  highwayTexture,
939
1103
  strikelineTexture,
940
- noteTextures
1104
+ noteTextures,
1105
+ animatedTextureManager
941
1106
  };
942
1107
  }
943
1108
  function adjustParsedChart(parsedChart, instrument, difficulty) {
package/dist/index.mjs CHANGED
@@ -35,6 +35,159 @@ function isAudioContextSupported() {
35
35
  var HIGHWAY_DURATION_MS = 1500;
36
36
  var SCALE = 0.105;
37
37
  var NOTE_SPAN_WIDTH = 0.95;
38
+ function isImageDecoderSupported() {
39
+ return typeof ImageDecoder !== "undefined";
40
+ }
41
+ var AnimatedTexture = class {
42
+ constructor(width, height) {
43
+ this.decoder = null;
44
+ this.frameIndex = 0;
45
+ this.frameCount = 0;
46
+ this.lastFrameTime = 0;
47
+ this.frameDurations = [];
48
+ this.isAnimated = false;
49
+ this.disposed = false;
50
+ this.canvas = document.createElement("canvas");
51
+ this.canvas.width = width;
52
+ this.canvas.height = height;
53
+ this.ctx = this.canvas.getContext("2d");
54
+ this.texture = new THREE.CanvasTexture(this.canvas);
55
+ this.texture.colorSpace = THREE.SRGBColorSpace;
56
+ }
57
+ /**
58
+ * Creates an AnimatedTexture from a URL.
59
+ * Uses ImageDecoder for animation if available, otherwise loads as static texture.
60
+ */
61
+ static async create(url) {
62
+ if (!isImageDecoderSupported()) {
63
+ const textureLoader = new THREE.TextureLoader();
64
+ return textureLoader.loadAsync(url);
65
+ }
66
+ try {
67
+ const response = await fetch(url);
68
+ if (!response.ok || !response.body) {
69
+ throw new Error(`Failed to fetch ${url}`);
70
+ }
71
+ const contentType = response.headers.get("content-type") || "image/webp";
72
+ const isSupported = await ImageDecoder.isTypeSupported(contentType);
73
+ if (!isSupported) {
74
+ const textureLoader = new THREE.TextureLoader();
75
+ return textureLoader.loadAsync(url);
76
+ }
77
+ const decoder = new ImageDecoder({
78
+ data: response.body,
79
+ type: contentType
80
+ });
81
+ await decoder.completed;
82
+ const track = decoder.tracks.selectedTrack;
83
+ if (!track) {
84
+ throw new Error("No track found in image");
85
+ }
86
+ const frameCount = track.frameCount;
87
+ if (frameCount <= 1) {
88
+ const result = await decoder.decode({ frameIndex: 0 });
89
+ const frame = result.image;
90
+ const animTexture2 = new AnimatedTexture(
91
+ frame.displayWidth,
92
+ frame.displayHeight
93
+ );
94
+ animTexture2.ctx.drawImage(frame, 0, 0);
95
+ animTexture2.texture.needsUpdate = true;
96
+ frame.close();
97
+ decoder.close();
98
+ return animTexture2.texture;
99
+ }
100
+ const firstResult = await decoder.decode({ frameIndex: 0 });
101
+ const firstFrame = firstResult.image;
102
+ const animTexture = new AnimatedTexture(
103
+ firstFrame.displayWidth,
104
+ firstFrame.displayHeight
105
+ );
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
+ animTexture.frameDurations = [];
112
+ for (let i = 0; i < frameCount; i++) {
113
+ try {
114
+ const frameResult = await decoder.decode({ frameIndex: i });
115
+ const durationMs = (frameResult.image.duration ?? 1e5) / 1e3;
116
+ animTexture.frameDurations.push(durationMs);
117
+ frameResult.image.close();
118
+ } catch {
119
+ animTexture.frameDurations.push(100);
120
+ }
121
+ }
122
+ firstFrame.close();
123
+ animTexture.lastFrameTime = performance.now();
124
+ return animTexture;
125
+ } catch (error) {
126
+ console.warn(
127
+ "Failed to load animated texture, falling back to static:",
128
+ error
129
+ );
130
+ const textureLoader = new THREE.TextureLoader();
131
+ return textureLoader.loadAsync(url);
132
+ }
133
+ }
134
+ /**
135
+ * Updates the texture to the current animation frame based on elapsed time.
136
+ * Call this each frame in the render loop.
137
+ */
138
+ async tick() {
139
+ if (!this.isAnimated || !this.decoder || this.disposed) {
140
+ return;
141
+ }
142
+ const now = performance.now();
143
+ const elapsed = now - this.lastFrameTime;
144
+ const currentFrameDuration = this.frameDurations[this.frameIndex] || 100;
145
+ if (elapsed >= currentFrameDuration) {
146
+ this.frameIndex = (this.frameIndex + 1) % this.frameCount;
147
+ this.lastFrameTime = now;
148
+ try {
149
+ const result = await this.decoder.decode({
150
+ frameIndex: this.frameIndex
151
+ });
152
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
153
+ this.ctx.drawImage(result.image, 0, 0);
154
+ this.texture.needsUpdate = true;
155
+ result.image.close();
156
+ } catch {
157
+ }
158
+ }
159
+ }
160
+ /**
161
+ * Disposes of resources used by this animated texture.
162
+ */
163
+ dispose() {
164
+ this.disposed = true;
165
+ if (this.decoder) {
166
+ this.decoder.close();
167
+ this.decoder = null;
168
+ }
169
+ this.texture.dispose();
170
+ }
171
+ };
172
+ var AnimatedTextureManager = class {
173
+ constructor() {
174
+ this.animatedTextures = [];
175
+ }
176
+ register(texture) {
177
+ if (texture instanceof AnimatedTexture) {
178
+ this.animatedTextures.push(texture);
179
+ }
180
+ }
181
+ async tick() {
182
+ await Promise.all(this.animatedTextures.map((t) => t.tick()));
183
+ }
184
+ dispose() {
185
+ for (const texture of this.animatedTextures) {
186
+ texture.dispose();
187
+ }
188
+ this.animatedTextures = [];
189
+ }
190
+ };
38
191
  var BARRE1_TYPE = 99991;
39
192
  var BARRE2_TYPE = 99992;
40
193
  var BARRE3_TYPE = 99993;
@@ -106,6 +259,7 @@ var _ChartPreview = class {
106
259
  const chartPreview = new _ChartPreview();
107
260
  chartPreview.instrumentType = getInstrumentType(config.instrument);
108
261
  chartPreview.highwayTexture = config.textures.highwayTexture;
262
+ chartPreview.animatedTextureManager = config.textures.animatedTextureManager;
109
263
  chartPreview.progressIntervalMs = config.progressIntervalMs ?? DEFAULT_PROGRESS_INTERVAL_MS;
110
264
  chartPreview.camera = new ChartCamera(config.container);
111
265
  chartPreview.renderer = new ChartRenderer(config.container);
@@ -190,6 +344,7 @@ var _ChartPreview = class {
190
344
  this.renderer.dispose();
191
345
  this.renderer.forceContextLoss();
192
346
  this.audioManager.closeAudio();
347
+ this.animatedTextureManager.dispose();
193
348
  }
194
349
  addHighwayToScene(highwayTexture) {
195
350
  const mat = new THREE.MeshBasicMaterial({ map: highwayTexture });
@@ -225,6 +380,7 @@ var _ChartPreview = class {
225
380
  this.notesManager.updateDisplayedNotes(
226
381
  this.audioManager.chartCurrentTimeMs
227
382
  );
383
+ this.animatedTextureManager.tick();
228
384
  const scrollPosition = -0.9 * (this.audioManager.chartCurrentTimeMs / 1e3) * (HIGHWAY_DURATION_MS / 1e3);
229
385
  this.highwayTexture.offset.y = -1 * scrollPosition;
230
386
  this.renderer.render(this.scene, this.camera);
@@ -709,7 +865,17 @@ var EventSequence = class {
709
865
  };
710
866
  async function loadTextures(instrumentType) {
711
867
  const textureLoader = new THREE.TextureLoader();
868
+ const animatedTextureManager = new AnimatedTextureManager();
712
869
  const load = (path) => textureLoader.loadAsync("https://static.enchor.us/" + path);
870
+ const loadAnimated = async (path) => {
871
+ const url = `https://static.enchor.us/preview-${path}.webp`;
872
+ const result = await AnimatedTexture.create(url);
873
+ if (result instanceof AnimatedTexture) {
874
+ animatedTextureManager.register(result);
875
+ return result.texture;
876
+ }
877
+ return result;
878
+ };
713
879
  const [highwayTexture, strikelineTexture, noteTextures] = await Promise.all([
714
880
  (async () => {
715
881
  const texture = await load("preview-highway.png");
@@ -731,9 +897,7 @@ async function loadTextures(instrumentType) {
731
897
  (async () => {
732
898
  const texturePromises = [];
733
899
  const addTexture = (type, flags, path) => {
734
- const texture = textureLoader.loadAsync(
735
- `https://static.enchor.us/preview-${path}.webp`
736
- );
900
+ const texture = loadAnimated(path);
737
901
  texturePromises.push({ type, flags, texture });
738
902
  return texture;
739
903
  };
@@ -898,7 +1062,8 @@ async function loadTextures(instrumentType) {
898
1062
  return {
899
1063
  highwayTexture,
900
1064
  strikelineTexture,
901
- noteTextures
1065
+ noteTextures,
1066
+ animatedTextureManager
902
1067
  };
903
1068
  }
904
1069
  function adjustParsedChart(parsedChart, instrument, difficulty) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chart-preview",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "author": "Geo",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -25,6 +25,203 @@ const HIGHWAY_DURATION_MS = 1500;
25
25
  const SCALE = 0.105;
26
26
  const NOTE_SPAN_WIDTH = 0.95;
27
27
 
28
+ /**
29
+ * Check if the ImageDecoder API is available for animated WebP support.
30
+ * This API is available in Chromium-based browsers (Chrome, Edge, Opera).
31
+ */
32
+ function isImageDecoderSupported(): boolean {
33
+ return typeof ImageDecoder !== "undefined";
34
+ }
35
+
36
+ /**
37
+ * Manages an animated WebP texture using the ImageDecoder API.
38
+ * Falls back to a static texture if ImageDecoder is not supported.
39
+ */
40
+ class AnimatedTexture {
41
+ private canvas: HTMLCanvasElement;
42
+ private ctx: CanvasRenderingContext2D;
43
+ public texture: THREE.CanvasTexture;
44
+ private decoder: ImageDecoder | null = null;
45
+ private frameIndex = 0;
46
+ private frameCount = 0;
47
+ private lastFrameTime = 0;
48
+ private frameDurations: number[] = [];
49
+ private isAnimated = false;
50
+ private disposed = false;
51
+
52
+ private constructor(width: number, height: number) {
53
+ this.canvas = document.createElement("canvas");
54
+ this.canvas.width = width;
55
+ this.canvas.height = height;
56
+ this.ctx = this.canvas.getContext("2d")!;
57
+ this.texture = new THREE.CanvasTexture(this.canvas);
58
+ this.texture.colorSpace = THREE.SRGBColorSpace;
59
+ }
60
+
61
+ /**
62
+ * Creates an AnimatedTexture from a URL.
63
+ * Uses ImageDecoder for animation if available, otherwise loads as static texture.
64
+ */
65
+ static async create(url: string): Promise<AnimatedTexture | THREE.Texture> {
66
+ if (!isImageDecoderSupported()) {
67
+ // Fall back to static texture loading
68
+ const textureLoader = new THREE.TextureLoader();
69
+ return textureLoader.loadAsync(url);
70
+ }
71
+
72
+ try {
73
+ const response = await fetch(url);
74
+ if (!response.ok || !response.body) {
75
+ throw new Error(`Failed to fetch ${url}`);
76
+ }
77
+
78
+ // Check if the image type is supported for decoding
79
+ const contentType = response.headers.get("content-type") || "image/webp";
80
+ const isSupported = await ImageDecoder.isTypeSupported(contentType);
81
+ if (!isSupported) {
82
+ // Fall back to static texture
83
+ const textureLoader = new THREE.TextureLoader();
84
+ return textureLoader.loadAsync(url);
85
+ }
86
+
87
+ const decoder = new ImageDecoder({
88
+ data: response.body,
89
+ type: contentType,
90
+ });
91
+
92
+ await decoder.completed;
93
+
94
+ const track = decoder.tracks.selectedTrack;
95
+ if (!track) {
96
+ throw new Error("No track found in image");
97
+ }
98
+
99
+ const frameCount = track.frameCount;
100
+
101
+ // If only one frame, just return a static texture
102
+ if (frameCount <= 1) {
103
+ const result = await decoder.decode({ frameIndex: 0 });
104
+ const frame = result.image;
105
+ const animTexture = new AnimatedTexture(
106
+ frame.displayWidth,
107
+ frame.displayHeight
108
+ );
109
+ animTexture.ctx.drawImage(frame, 0, 0);
110
+ animTexture.texture.needsUpdate = true;
111
+ frame.close();
112
+ decoder.close();
113
+ return animTexture.texture;
114
+ }
115
+
116
+ // Animated image - decode first frame and set up animation
117
+ const firstResult = await decoder.decode({ frameIndex: 0 });
118
+ const firstFrame = firstResult.image;
119
+ const animTexture = new AnimatedTexture(
120
+ firstFrame.displayWidth,
121
+ firstFrame.displayHeight
122
+ );
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
+
130
+ // Pre-decode frame durations
131
+ animTexture.frameDurations = [];
132
+ for (let i = 0; i < frameCount; i++) {
133
+ try {
134
+ const frameResult = await decoder.decode({ frameIndex: i });
135
+ // Duration is in microseconds, convert to milliseconds
136
+ const durationMs = (frameResult.image.duration ?? 100000) / 1000;
137
+ animTexture.frameDurations.push(durationMs);
138
+ frameResult.image.close();
139
+ } catch {
140
+ animTexture.frameDurations.push(100); // Default 100ms
141
+ }
142
+ }
143
+
144
+ firstFrame.close();
145
+ animTexture.lastFrameTime = performance.now();
146
+
147
+ return animTexture;
148
+ } catch (error) {
149
+ // Fall back to static texture on any error
150
+ console.warn(
151
+ "Failed to load animated texture, falling back to static:",
152
+ error
153
+ );
154
+ const textureLoader = new THREE.TextureLoader();
155
+ return textureLoader.loadAsync(url);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Updates the texture to the current animation frame based on elapsed time.
161
+ * Call this each frame in the render loop.
162
+ */
163
+ async tick(): Promise<void> {
164
+ if (!this.isAnimated || !this.decoder || this.disposed) {
165
+ return;
166
+ }
167
+
168
+ const now = performance.now();
169
+ const elapsed = now - this.lastFrameTime;
170
+ const currentFrameDuration = this.frameDurations[this.frameIndex] || 100;
171
+
172
+ if (elapsed >= currentFrameDuration) {
173
+ this.frameIndex = (this.frameIndex + 1) % this.frameCount;
174
+ this.lastFrameTime = now;
175
+
176
+ try {
177
+ const result = await this.decoder.decode({
178
+ frameIndex: this.frameIndex,
179
+ });
180
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
181
+ this.ctx.drawImage(result.image, 0, 0);
182
+ this.texture.needsUpdate = true;
183
+ result.image.close();
184
+ } catch {
185
+ // Frame decode failed, skip this frame
186
+ }
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Disposes of resources used by this animated texture.
192
+ */
193
+ dispose(): void {
194
+ this.disposed = true;
195
+ if (this.decoder) {
196
+ this.decoder.close();
197
+ this.decoder = null;
198
+ }
199
+ this.texture.dispose();
200
+ }
201
+ }
202
+
203
+ /** Collection of animated textures that need to be ticked each frame */
204
+ class AnimatedTextureManager {
205
+ private animatedTextures: AnimatedTexture[] = [];
206
+
207
+ register(texture: AnimatedTexture | THREE.Texture): void {
208
+ if (texture instanceof AnimatedTexture) {
209
+ this.animatedTextures.push(texture);
210
+ }
211
+ }
212
+
213
+ async tick(): Promise<void> {
214
+ await Promise.all(this.animatedTextures.map((t) => t.tick()));
215
+ }
216
+
217
+ dispose(): void {
218
+ for (const texture of this.animatedTextures) {
219
+ texture.dispose();
220
+ }
221
+ this.animatedTextures = [];
222
+ }
223
+ }
224
+
28
225
  /**
29
226
  * Custom note types for 6-fret barre notes.
30
227
  * These are the only sprites without a dedicated NoteType from scan-chart.
@@ -89,6 +286,7 @@ export class ChartPreview {
89
286
  private renderer: ChartRenderer;
90
287
  private audioManager: AudioManager | SilentAudioManager;
91
288
  private notesManager: NotesManager;
289
+ private animatedTextureManager: AnimatedTextureManager;
92
290
  private progressIntervalMs: number;
93
291
  private lastProgressEmitTime = 0;
94
292
 
@@ -162,6 +360,8 @@ export class ChartPreview {
162
360
  const chartPreview = new ChartPreview();
163
361
  chartPreview.instrumentType = getInstrumentType(config.instrument);
164
362
  chartPreview.highwayTexture = config.textures.highwayTexture;
363
+ chartPreview.animatedTextureManager =
364
+ config.textures.animatedTextureManager;
165
365
  chartPreview.progressIntervalMs =
166
366
  config.progressIntervalMs ?? DEFAULT_PROGRESS_INTERVAL_MS;
167
367
  chartPreview.camera = new ChartCamera(config.container);
@@ -257,6 +457,7 @@ export class ChartPreview {
257
457
  this.renderer.dispose();
258
458
  this.renderer.forceContextLoss();
259
459
  this.audioManager.closeAudio();
460
+ this.animatedTextureManager.dispose();
260
461
  }
261
462
 
262
463
  private addHighwayToScene(highwayTexture: THREE.Texture) {
@@ -307,6 +508,9 @@ export class ChartPreview {
307
508
  this.audioManager.chartCurrentTimeMs
308
509
  );
309
510
 
511
+ // Update animated textures (for animated WebP notes)
512
+ this.animatedTextureManager.tick();
513
+
310
514
  // Shift highway position
311
515
  const scrollPosition =
312
516
  -0.9 *
@@ -976,13 +1180,31 @@ class EventSequence<
976
1180
  /**
977
1181
  * Loads all required textures for the chart preview.
978
1182
  * @param instrumentType The type of instrument to load textures for
979
- * @returns An object containing highway, strikeline, and note textures
1183
+ * @returns An object containing highway, strikeline, note textures, and animated texture manager
980
1184
  */
981
1185
  async function loadTextures(instrumentType: InstrumentType) {
982
1186
  const textureLoader = new THREE.TextureLoader();
1187
+ const animatedTextureManager = new AnimatedTextureManager();
1188
+
983
1189
  const load = (path: string) =>
984
1190
  textureLoader.loadAsync("https://static.enchor.us/" + path);
985
1191
 
1192
+ /**
1193
+ * Loads a texture with animation support.
1194
+ * Uses ImageDecoder API for animated WebP when available, falls back to static.
1195
+ */
1196
+ const loadAnimated = async (path: string): Promise<THREE.Texture> => {
1197
+ const url = `https://static.enchor.us/preview-${path}.webp`;
1198
+ const result = await AnimatedTexture.create(url);
1199
+
1200
+ if (result instanceof AnimatedTexture) {
1201
+ animatedTextureManager.register(result);
1202
+ return result.texture;
1203
+ }
1204
+
1205
+ return result;
1206
+ };
1207
+
986
1208
  const [highwayTexture, strikelineTexture, noteTextures] = await Promise.all([
987
1209
  (async () => {
988
1210
  const texture = await load("preview-highway.png");
@@ -1014,9 +1236,7 @@ async function loadTextures(instrumentType: InstrumentType) {
1014
1236
  flags: number,
1015
1237
  path: string
1016
1238
  ) => {
1017
- const texture = textureLoader.loadAsync(
1018
- `https://static.enchor.us/preview-${path}.webp`
1019
- );
1239
+ const texture = loadAnimated(path);
1020
1240
  texturePromises.push({ type, flags, texture });
1021
1241
  return texture;
1022
1242
  };
@@ -1199,6 +1419,7 @@ async function loadTextures(instrumentType: InstrumentType) {
1199
1419
  highwayTexture,
1200
1420
  strikelineTexture,
1201
1421
  noteTextures,
1422
+ animatedTextureManager,
1202
1423
  };
1203
1424
  }
1204
1425