chart-preview 1.0.3 → 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.js CHANGED
@@ -31,6 +31,7 @@ var src_exports = {};
31
31
  __export(src_exports, {
32
32
  ChartPreview: () => ChartPreview,
33
33
  ChartPreviewPlayer: () => ChartPreviewPlayer,
34
+ areAnimationsSupported: () => areAnimationsSupported,
34
35
  extractSngFile: () => extractSngFile,
35
36
  fetchSngFile: () => fetchSngFile,
36
37
  findAudioFiles: () => findAudioFiles,
@@ -74,6 +75,196 @@ function isAudioContextSupported() {
74
75
  var HIGHWAY_DURATION_MS = 1500;
75
76
  var SCALE = 0.105;
76
77
  var NOTE_SPAN_WIDTH = 0.95;
78
+ function isImageDecoderSupported() {
79
+ return typeof ImageDecoder !== "undefined";
80
+ }
81
+ function areAnimationsSupported() {
82
+ return isImageDecoderSupported();
83
+ }
84
+ var AnimatedTexture = class {
85
+ constructor(width, height) {
86
+ this.frameIndex = 0;
87
+ this.frameCount = 0;
88
+ this.lastFrameTime = 0;
89
+ this.frameDurations = [];
90
+ /** Pre-decoded frames stored as ImageBitmap for fast synchronous access */
91
+ this.frameCache = [];
92
+ this.isAnimated = false;
93
+ this.disposed = false;
94
+ this.canvas = document.createElement("canvas");
95
+ this.canvas.width = width;
96
+ this.canvas.height = height;
97
+ this.ctx = this.canvas.getContext("2d");
98
+ this.texture = new THREE.CanvasTexture(this.canvas);
99
+ this.texture.colorSpace = THREE.SRGBColorSpace;
100
+ }
101
+ /**
102
+ * Creates an AnimatedTexture from a URL.
103
+ * Uses ImageDecoder for animation if available, otherwise loads as static texture.
104
+ * Pre-decodes all frames during initialization for optimal render performance.
105
+ */
106
+ static async create(url) {
107
+ if (!isImageDecoderSupported()) {
108
+ return loadStaticTexture(url);
109
+ }
110
+ try {
111
+ const response = await fetch(url);
112
+ if (!response.ok || !response.body) {
113
+ throw new Error(`Failed to fetch ${url}`);
114
+ }
115
+ const contentType = response.headers.get("content-type") || "image/webp";
116
+ const isSupported = await ImageDecoder.isTypeSupported(contentType);
117
+ if (!isSupported) {
118
+ return loadStaticTexture(url);
119
+ }
120
+ const decoder = new ImageDecoder({
121
+ data: response.body,
122
+ type: contentType
123
+ });
124
+ await decoder.completed;
125
+ const track = decoder.tracks.selectedTrack;
126
+ if (!track) {
127
+ throw new Error("No track found in image");
128
+ }
129
+ const frameCount = track.frameCount;
130
+ if (frameCount <= 1) {
131
+ const result = await decoder.decode({ frameIndex: 0 });
132
+ const frame = result.image;
133
+ const animTexture2 = new AnimatedTexture(
134
+ frame.displayWidth,
135
+ frame.displayHeight
136
+ );
137
+ animTexture2.ctx.drawImage(frame, 0, 0);
138
+ animTexture2.texture.needsUpdate = true;
139
+ frame.close();
140
+ decoder.close();
141
+ return animTexture2.texture;
142
+ }
143
+ const firstResult = await decoder.decode({ frameIndex: 0 });
144
+ const firstFrame = firstResult.image;
145
+ const animTexture = new AnimatedTexture(
146
+ firstFrame.displayWidth,
147
+ firstFrame.displayHeight
148
+ );
149
+ animTexture.frameDurations = [];
150
+ animTexture.frameCache = [];
151
+ for (let i = 0; i < frameCount; i++) {
152
+ try {
153
+ const frameResult = await decoder.decode({ frameIndex: i });
154
+ const videoFrame = frameResult.image;
155
+ const bitmap = await createImageBitmap(videoFrame);
156
+ animTexture.frameCache.push(bitmap);
157
+ const durationMs = (videoFrame.duration ?? 1e5) / 1e3;
158
+ animTexture.frameDurations.push(durationMs);
159
+ videoFrame.close();
160
+ } catch {
161
+ animTexture.frameDurations.push(100);
162
+ if (animTexture.frameCache.length > 0) {
163
+ animTexture.frameCache.push(
164
+ animTexture.frameCache[animTexture.frameCache.length - 1]
165
+ );
166
+ }
167
+ }
168
+ }
169
+ if (animTexture.frameCache.length > 0) {
170
+ animTexture.ctx.drawImage(animTexture.frameCache[0], 0, 0);
171
+ animTexture.texture.needsUpdate = true;
172
+ }
173
+ animTexture.frameCount = animTexture.frameCache.length;
174
+ animTexture.isAnimated = animTexture.frameCount > 1;
175
+ animTexture.lastFrameTime = performance.now();
176
+ firstFrame.close();
177
+ decoder.close();
178
+ return animTexture;
179
+ } catch (error) {
180
+ console.warn(
181
+ "Failed to load animated texture, falling back to static:",
182
+ error
183
+ );
184
+ return loadStaticTexture(url);
185
+ }
186
+ }
187
+ /**
188
+ * Updates the texture to the current animation frame based on elapsed time.
189
+ * This method is SYNCHRONOUS for optimal render performance - all frames
190
+ * are pre-decoded during initialization.
191
+ */
192
+ tick() {
193
+ if (!this.isAnimated || this.disposed || this.frameCache.length === 0) {
194
+ return;
195
+ }
196
+ const now = performance.now();
197
+ const elapsed = now - this.lastFrameTime;
198
+ const currentFrameDuration = this.frameDurations[this.frameIndex] || 100;
199
+ if (elapsed >= currentFrameDuration) {
200
+ this.frameIndex = (this.frameIndex + 1) % this.frameCount;
201
+ this.lastFrameTime = now;
202
+ const frame = this.frameCache[this.frameIndex];
203
+ if (frame) {
204
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
205
+ this.ctx.drawImage(frame, 0, 0);
206
+ this.texture.needsUpdate = true;
207
+ }
208
+ }
209
+ }
210
+ /**
211
+ * Disposes of resources used by this animated texture.
212
+ */
213
+ dispose() {
214
+ this.disposed = true;
215
+ for (const bitmap of this.frameCache) {
216
+ bitmap.close();
217
+ }
218
+ this.frameCache = [];
219
+ this.texture.dispose();
220
+ }
221
+ };
222
+ async function loadStaticTexture(url) {
223
+ const textureLoader = new THREE.TextureLoader();
224
+ try {
225
+ const texture = await textureLoader.loadAsync(url);
226
+ texture.colorSpace = THREE.SRGBColorSpace;
227
+ return texture;
228
+ } catch (error) {
229
+ console.warn(`Failed to load static texture from ${url}:`, error);
230
+ return createPlaceholderTexture();
231
+ }
232
+ }
233
+ function createPlaceholderTexture() {
234
+ const canvas = document.createElement("canvas");
235
+ canvas.width = 32;
236
+ canvas.height = 32;
237
+ const ctx = canvas.getContext("2d");
238
+ ctx.fillStyle = "#FF00FF";
239
+ ctx.fillRect(0, 0, 32, 32);
240
+ const texture = new THREE.CanvasTexture(canvas);
241
+ texture.colorSpace = THREE.SRGBColorSpace;
242
+ return texture;
243
+ }
244
+ var AnimatedTextureManager = class {
245
+ constructor() {
246
+ this.animatedTextures = [];
247
+ }
248
+ register(texture) {
249
+ if (texture instanceof AnimatedTexture) {
250
+ this.animatedTextures.push(texture);
251
+ }
252
+ }
253
+ /**
254
+ * Updates all animated textures. This is now SYNCHRONOUS for optimal performance.
255
+ */
256
+ tick() {
257
+ for (const texture of this.animatedTextures) {
258
+ texture.tick();
259
+ }
260
+ }
261
+ dispose() {
262
+ for (const texture of this.animatedTextures) {
263
+ texture.dispose();
264
+ }
265
+ this.animatedTextures = [];
266
+ }
267
+ };
77
268
  var BARRE1_TYPE = 99991;
78
269
  var BARRE2_TYPE = 99992;
79
270
  var BARRE3_TYPE = 99993;
@@ -145,6 +336,7 @@ var _ChartPreview = class {
145
336
  const chartPreview = new _ChartPreview();
146
337
  chartPreview.instrumentType = (0, import_scan_chart.getInstrumentType)(config.instrument);
147
338
  chartPreview.highwayTexture = config.textures.highwayTexture;
339
+ chartPreview.animatedTextureManager = config.textures.animatedTextureManager;
148
340
  chartPreview.progressIntervalMs = config.progressIntervalMs ?? DEFAULT_PROGRESS_INTERVAL_MS;
149
341
  chartPreview.camera = new ChartCamera(config.container);
150
342
  chartPreview.renderer = new ChartRenderer(config.container);
@@ -229,6 +421,7 @@ var _ChartPreview = class {
229
421
  this.renderer.dispose();
230
422
  this.renderer.forceContextLoss();
231
423
  this.audioManager.closeAudio();
424
+ this.animatedTextureManager.dispose();
232
425
  }
233
426
  addHighwayToScene(highwayTexture) {
234
427
  const mat = new THREE.MeshBasicMaterial({ map: highwayTexture });
@@ -264,6 +457,7 @@ var _ChartPreview = class {
264
457
  this.notesManager.updateDisplayedNotes(
265
458
  this.audioManager.chartCurrentTimeMs
266
459
  );
460
+ this.animatedTextureManager.tick();
267
461
  const scrollPosition = -0.9 * (this.audioManager.chartCurrentTimeMs / 1e3) * (HIGHWAY_DURATION_MS / 1e3);
268
462
  this.highwayTexture.offset.y = -1 * scrollPosition;
269
463
  this.renderer.render(this.scene, this.camera);
@@ -665,11 +859,16 @@ var NotesManager = class {
665
859
  }
666
860
  for (let i = maxNoteEventIndex + 1; this.noteEvents[i] && this.noteEvents[i].msTime < renderEndTimeMs; i++) {
667
861
  const note = this.noteEvents[i];
862
+ const material = this.noteMaterials.get(note.type)?.get(note.flags);
863
+ if (!material) {
864
+ console.warn(
865
+ `Missing texture for note type ${note.type} with flags ${note.flags}`
866
+ );
867
+ continue;
868
+ }
668
869
  const noteGroup = new THREE.Group();
669
870
  const scale = note.type === import_scan_chart.noteTypes.kick ? 0.045 : note.type === import_scan_chart.noteTypes.open && this.instrumentType === import_scan_chart.instrumentTypes.sixFret ? 0.04 : SCALE;
670
- const sprite = new THREE.Sprite(
671
- this.noteMaterials.get(note.type).get(note.flags)
672
- );
871
+ const sprite = new THREE.Sprite(material);
673
872
  noteGroup.add(sprite);
674
873
  sprite.center = new THREE.Vector2(
675
874
  note.type === import_scan_chart.noteTypes.kick ? 0.62 : 0.5,
@@ -746,9 +945,23 @@ var EventSequence = class {
746
945
  return earliestActiveEventIndex === null ? this.lastPrecedingEventIndex + 1 : earliestActiveEventIndex;
747
946
  }
748
947
  };
749
- async function loadTextures(instrumentType) {
948
+ async function loadTextures(instrumentType, options = {}) {
949
+ const { animationsEnabled = true } = options;
750
950
  const textureLoader = new THREE.TextureLoader();
951
+ const animatedTextureManager = new AnimatedTextureManager();
751
952
  const load = (path) => textureLoader.loadAsync("https://static.enchor.us/" + path);
953
+ const loadAnimated = async (path) => {
954
+ const url = `https://static.enchor.us/preview-${path}.webp`;
955
+ if (!animationsEnabled) {
956
+ return loadStaticTexture(url);
957
+ }
958
+ const result = await AnimatedTexture.create(url);
959
+ if (result instanceof AnimatedTexture) {
960
+ animatedTextureManager.register(result);
961
+ return result.texture;
962
+ }
963
+ return result;
964
+ };
752
965
  const [highwayTexture, strikelineTexture, noteTextures] = await Promise.all([
753
966
  (async () => {
754
967
  const texture = await load("preview-highway.png");
@@ -770,9 +983,7 @@ async function loadTextures(instrumentType) {
770
983
  (async () => {
771
984
  const texturePromises = [];
772
985
  const addTexture = (type, flags, path) => {
773
- const texture = textureLoader.loadAsync(
774
- `https://static.enchor.us/preview-${path}.webp`
775
- );
986
+ const texture = loadAnimated(path);
776
987
  texturePromises.push({ type, flags, texture });
777
988
  return texture;
778
989
  };
@@ -937,7 +1148,8 @@ async function loadTextures(instrumentType) {
937
1148
  return {
938
1149
  highwayTexture,
939
1150
  strikelineTexture,
940
- noteTextures
1151
+ noteTextures,
1152
+ animatedTextureManager
941
1153
  };
942
1154
  }
943
1155
  function adjustParsedChart(parsedChart, instrument, difficulty) {
@@ -1184,7 +1396,7 @@ function isVideoFile(fileName) {
1184
1396
  const ext = fileName.toLowerCase().slice(fileName.lastIndexOf("."));
1185
1397
  return videoExtensions.includes(ext);
1186
1398
  }
1187
- async function prepareChartData(files, instrument, difficulty, initialSeekPercent = 0) {
1399
+ async function prepareChartData(files, instrument, difficulty, initialSeekPercent = 0, textureOptions = {}) {
1188
1400
  const filteredFiles = files.filter((f) => !isVideoFile(f.fileName));
1189
1401
  const scannedChart = (0, import_scan_chart2.scanChartFolder)(filteredFiles);
1190
1402
  if (!scannedChart.playable || !scannedChart.notesData) {
@@ -1219,7 +1431,10 @@ async function prepareChartData(files, instrument, difficulty, initialSeekPercen
1219
1431
  }
1220
1432
  const audioFiles = findAudioFiles(filteredFiles);
1221
1433
  const instrumentType = (0, import_scan_chart2.getInstrumentType)(instrument);
1222
- const textures = await ChartPreview.loadTextures(instrumentType);
1434
+ const textures = await ChartPreview.loadTextures(
1435
+ instrumentType,
1436
+ textureOptions
1437
+ );
1223
1438
  const lastNote = parsedChart.trackData.flatMap((t) => t.noteEventGroups.flat()).reduce((max, note) => Math.max(max, note.msTime + note.msLength), 0);
1224
1439
  const audioLengthMs = scannedChart.song_length || lastNote + 5e3;
1225
1440
  const startDelayMs = (scannedChart.delay || 0) + (scannedChart.chart_offset || 0) * 1e3;
@@ -1540,10 +1755,11 @@ var ChartPreviewPlayer = class extends HTMLElement {
1540
1755
  }
1541
1756
  };
1542
1757
  this.handleFullscreenChange = () => {
1543
- this._isFullscreen = document.fullscreenElement === this.container;
1758
+ const fullscreenElement = document.fullscreenElement || document.webkitFullscreenElement;
1759
+ this._isFullscreen = fullscreenElement !== null && fullscreenElement !== void 0;
1544
1760
  this.container.classList.toggle("fullscreen", this._isFullscreen);
1545
1761
  this.fullscreenBtn.innerHTML = this._isFullscreen ? ICONS.fullscreenExit : ICONS.fullscreen;
1546
- this.fullscreenBtn.title = this._isFullscreen ? "Exit fullscreen" : "Toggle fullscreen";
1762
+ this.fullscreenBtn.title = this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
1547
1763
  };
1548
1764
  this.shadow = this.attachShadow({ mode: "open" });
1549
1765
  this.render();
@@ -1568,6 +1784,10 @@ var ChartPreviewPlayer = class extends HTMLElement {
1568
1784
  connectedCallback() {
1569
1785
  this.setAttribute("tabindex", "0");
1570
1786
  document.addEventListener("fullscreenchange", this.handleFullscreenChange);
1787
+ document.addEventListener(
1788
+ "webkitfullscreenchange",
1789
+ this.handleFullscreenChange
1790
+ );
1571
1791
  }
1572
1792
  disconnectedCallback() {
1573
1793
  this.dispose();
@@ -1575,6 +1795,10 @@ var ChartPreviewPlayer = class extends HTMLElement {
1575
1795
  "fullscreenchange",
1576
1796
  this.handleFullscreenChange
1577
1797
  );
1798
+ document.removeEventListener(
1799
+ "webkitfullscreenchange",
1800
+ this.handleFullscreenChange
1801
+ );
1578
1802
  this.removeEventListener("keydown", this.handleKeydown);
1579
1803
  }
1580
1804
  render() {
@@ -1655,7 +1879,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
1655
1879
  this.onSeekEnd();
1656
1880
  }
1657
1881
  });
1658
- this.fullscreenBtn.addEventListener("click", () => {
1882
+ this.fullscreenBtn.addEventListener("click", (e) => {
1883
+ e.stopPropagation();
1659
1884
  this.toggleFullscreen();
1660
1885
  });
1661
1886
  this.addEventListener("keydown", this.handleKeydown);
@@ -1968,7 +2193,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
1968
2193
  files,
1969
2194
  config.instrument,
1970
2195
  config.difficulty,
1971
- config.initialSeekPercent
2196
+ config.initialSeekPercent,
2197
+ { animationsEnabled: config.animationsEnabled }
1972
2198
  );
1973
2199
  await this.loadChart({
1974
2200
  parsedChart: preparedData.parsedChart,
@@ -2010,7 +2236,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
2010
2236
  files,
2011
2237
  config.instrument,
2012
2238
  config.difficulty,
2013
- config.initialSeekPercent
2239
+ config.initialSeekPercent,
2240
+ { animationsEnabled: config.animationsEnabled }
2014
2241
  );
2015
2242
  await this.loadChart({
2016
2243
  parsedChart: preparedData.parsedChart,
@@ -2049,7 +2276,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
2049
2276
  config.files,
2050
2277
  config.instrument,
2051
2278
  config.difficulty,
2052
- config.initialSeekPercent
2279
+ config.initialSeekPercent,
2280
+ { animationsEnabled: config.animationsEnabled }
2053
2281
  );
2054
2282
  await this.loadChart({
2055
2283
  parsedChart: preparedData.parsedChart,
@@ -2149,12 +2377,28 @@ var ChartPreviewPlayer = class extends HTMLElement {
2149
2377
  * Toggle fullscreen mode
2150
2378
  */
2151
2379
  async toggleFullscreen() {
2152
- if (!document.fullscreenEnabled)
2380
+ const doc = document;
2381
+ const container = this.container;
2382
+ const isFullscreenEnabled = doc.fullscreenEnabled || doc.webkitFullscreenEnabled;
2383
+ if (!isFullscreenEnabled)
2153
2384
  return;
2154
- if (this._isFullscreen) {
2155
- await document.exitFullscreen();
2156
- } else {
2157
- await this.container.requestFullscreen();
2385
+ const fullscreenElement = doc.fullscreenElement || doc.webkitFullscreenElement;
2386
+ try {
2387
+ if (fullscreenElement) {
2388
+ if (doc.exitFullscreen) {
2389
+ await doc.exitFullscreen();
2390
+ } else if (doc.webkitExitFullscreen) {
2391
+ doc.webkitExitFullscreen();
2392
+ }
2393
+ } else {
2394
+ if (container.requestFullscreen) {
2395
+ await container.requestFullscreen();
2396
+ } else if (container.webkitRequestFullscreen) {
2397
+ container.webkitRequestFullscreen();
2398
+ }
2399
+ }
2400
+ } catch (error) {
2401
+ console.warn("Fullscreen toggle failed:", error);
2158
2402
  }
2159
2403
  }
2160
2404
  /**
@@ -2187,6 +2431,7 @@ var import_scan_chart3 = require("scan-chart");
2187
2431
  0 && (module.exports = {
2188
2432
  ChartPreview,
2189
2433
  ChartPreviewPlayer,
2434
+ areAnimationsSupported,
2190
2435
  extractSngFile,
2191
2436
  fetchSngFile,
2192
2437
  findAudioFiles,