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.mjs CHANGED
@@ -35,6 +35,196 @@ 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
+ function areAnimationsSupported() {
42
+ return isImageDecoderSupported();
43
+ }
44
+ var AnimatedTexture = class {
45
+ constructor(width, height) {
46
+ this.frameIndex = 0;
47
+ this.frameCount = 0;
48
+ this.lastFrameTime = 0;
49
+ this.frameDurations = [];
50
+ /** Pre-decoded frames stored as ImageBitmap for fast synchronous access */
51
+ this.frameCache = [];
52
+ this.isAnimated = false;
53
+ this.disposed = false;
54
+ this.canvas = document.createElement("canvas");
55
+ this.canvas.width = width;
56
+ this.canvas.height = height;
57
+ this.ctx = this.canvas.getContext("2d");
58
+ this.texture = new THREE.CanvasTexture(this.canvas);
59
+ this.texture.colorSpace = THREE.SRGBColorSpace;
60
+ }
61
+ /**
62
+ * Creates an AnimatedTexture from a URL.
63
+ * Uses ImageDecoder for animation if available, otherwise loads as static texture.
64
+ * Pre-decodes all frames during initialization for optimal render performance.
65
+ */
66
+ static async create(url) {
67
+ if (!isImageDecoderSupported()) {
68
+ return loadStaticTexture(url);
69
+ }
70
+ try {
71
+ const response = await fetch(url);
72
+ if (!response.ok || !response.body) {
73
+ throw new Error(`Failed to fetch ${url}`);
74
+ }
75
+ const contentType = response.headers.get("content-type") || "image/webp";
76
+ const isSupported = await ImageDecoder.isTypeSupported(contentType);
77
+ if (!isSupported) {
78
+ return loadStaticTexture(url);
79
+ }
80
+ const decoder = new ImageDecoder({
81
+ data: response.body,
82
+ type: contentType
83
+ });
84
+ await decoder.completed;
85
+ const track = decoder.tracks.selectedTrack;
86
+ if (!track) {
87
+ throw new Error("No track found in image");
88
+ }
89
+ const frameCount = track.frameCount;
90
+ if (frameCount <= 1) {
91
+ const result = await decoder.decode({ frameIndex: 0 });
92
+ const frame = result.image;
93
+ const animTexture2 = new AnimatedTexture(
94
+ frame.displayWidth,
95
+ frame.displayHeight
96
+ );
97
+ animTexture2.ctx.drawImage(frame, 0, 0);
98
+ animTexture2.texture.needsUpdate = true;
99
+ frame.close();
100
+ decoder.close();
101
+ return animTexture2.texture;
102
+ }
103
+ const firstResult = await decoder.decode({ frameIndex: 0 });
104
+ const firstFrame = firstResult.image;
105
+ const animTexture = new AnimatedTexture(
106
+ firstFrame.displayWidth,
107
+ firstFrame.displayHeight
108
+ );
109
+ animTexture.frameDurations = [];
110
+ animTexture.frameCache = [];
111
+ for (let i = 0; i < frameCount; i++) {
112
+ try {
113
+ const frameResult = await decoder.decode({ frameIndex: i });
114
+ const videoFrame = frameResult.image;
115
+ const bitmap = await createImageBitmap(videoFrame);
116
+ animTexture.frameCache.push(bitmap);
117
+ const durationMs = (videoFrame.duration ?? 1e5) / 1e3;
118
+ animTexture.frameDurations.push(durationMs);
119
+ videoFrame.close();
120
+ } catch {
121
+ animTexture.frameDurations.push(100);
122
+ if (animTexture.frameCache.length > 0) {
123
+ animTexture.frameCache.push(
124
+ animTexture.frameCache[animTexture.frameCache.length - 1]
125
+ );
126
+ }
127
+ }
128
+ }
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;
135
+ animTexture.lastFrameTime = performance.now();
136
+ firstFrame.close();
137
+ decoder.close();
138
+ return animTexture;
139
+ } catch (error) {
140
+ console.warn(
141
+ "Failed to load animated texture, falling back to static:",
142
+ error
143
+ );
144
+ return loadStaticTexture(url);
145
+ }
146
+ }
147
+ /**
148
+ * Updates the texture to the current animation frame based on elapsed time.
149
+ * This method is SYNCHRONOUS for optimal render performance - all frames
150
+ * are pre-decoded during initialization.
151
+ */
152
+ tick() {
153
+ if (!this.isAnimated || this.disposed || this.frameCache.length === 0) {
154
+ return;
155
+ }
156
+ const now = performance.now();
157
+ const elapsed = now - this.lastFrameTime;
158
+ const currentFrameDuration = this.frameDurations[this.frameIndex] || 100;
159
+ if (elapsed >= currentFrameDuration) {
160
+ this.frameIndex = (this.frameIndex + 1) % this.frameCount;
161
+ this.lastFrameTime = now;
162
+ const frame = this.frameCache[this.frameIndex];
163
+ if (frame) {
164
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
165
+ this.ctx.drawImage(frame, 0, 0);
166
+ this.texture.needsUpdate = true;
167
+ }
168
+ }
169
+ }
170
+ /**
171
+ * Disposes of resources used by this animated texture.
172
+ */
173
+ dispose() {
174
+ this.disposed = true;
175
+ for (const bitmap of this.frameCache) {
176
+ bitmap.close();
177
+ }
178
+ this.frameCache = [];
179
+ this.texture.dispose();
180
+ }
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
+ }
204
+ var AnimatedTextureManager = class {
205
+ constructor() {
206
+ this.animatedTextures = [];
207
+ }
208
+ register(texture) {
209
+ if (texture instanceof AnimatedTexture) {
210
+ this.animatedTextures.push(texture);
211
+ }
212
+ }
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
+ }
220
+ }
221
+ dispose() {
222
+ for (const texture of this.animatedTextures) {
223
+ texture.dispose();
224
+ }
225
+ this.animatedTextures = [];
226
+ }
227
+ };
38
228
  var BARRE1_TYPE = 99991;
39
229
  var BARRE2_TYPE = 99992;
40
230
  var BARRE3_TYPE = 99993;
@@ -106,6 +296,7 @@ var _ChartPreview = class {
106
296
  const chartPreview = new _ChartPreview();
107
297
  chartPreview.instrumentType = getInstrumentType(config.instrument);
108
298
  chartPreview.highwayTexture = config.textures.highwayTexture;
299
+ chartPreview.animatedTextureManager = config.textures.animatedTextureManager;
109
300
  chartPreview.progressIntervalMs = config.progressIntervalMs ?? DEFAULT_PROGRESS_INTERVAL_MS;
110
301
  chartPreview.camera = new ChartCamera(config.container);
111
302
  chartPreview.renderer = new ChartRenderer(config.container);
@@ -190,6 +381,7 @@ var _ChartPreview = class {
190
381
  this.renderer.dispose();
191
382
  this.renderer.forceContextLoss();
192
383
  this.audioManager.closeAudio();
384
+ this.animatedTextureManager.dispose();
193
385
  }
194
386
  addHighwayToScene(highwayTexture) {
195
387
  const mat = new THREE.MeshBasicMaterial({ map: highwayTexture });
@@ -225,6 +417,7 @@ var _ChartPreview = class {
225
417
  this.notesManager.updateDisplayedNotes(
226
418
  this.audioManager.chartCurrentTimeMs
227
419
  );
420
+ this.animatedTextureManager.tick();
228
421
  const scrollPosition = -0.9 * (this.audioManager.chartCurrentTimeMs / 1e3) * (HIGHWAY_DURATION_MS / 1e3);
229
422
  this.highwayTexture.offset.y = -1 * scrollPosition;
230
423
  this.renderer.render(this.scene, this.camera);
@@ -626,11 +819,16 @@ var NotesManager = class {
626
819
  }
627
820
  for (let i = maxNoteEventIndex + 1; this.noteEvents[i] && this.noteEvents[i].msTime < renderEndTimeMs; i++) {
628
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
+ }
629
829
  const noteGroup = new THREE.Group();
630
830
  const scale = note.type === noteTypes.kick ? 0.045 : note.type === noteTypes.open && this.instrumentType === instrumentTypes.sixFret ? 0.04 : SCALE;
631
- const sprite = new THREE.Sprite(
632
- this.noteMaterials.get(note.type).get(note.flags)
633
- );
831
+ const sprite = new THREE.Sprite(material);
634
832
  noteGroup.add(sprite);
635
833
  sprite.center = new THREE.Vector2(
636
834
  note.type === noteTypes.kick ? 0.62 : 0.5,
@@ -707,9 +905,23 @@ var EventSequence = class {
707
905
  return earliestActiveEventIndex === null ? this.lastPrecedingEventIndex + 1 : earliestActiveEventIndex;
708
906
  }
709
907
  };
710
- async function loadTextures(instrumentType) {
908
+ async function loadTextures(instrumentType, options = {}) {
909
+ const { animationsEnabled = true } = options;
711
910
  const textureLoader = new THREE.TextureLoader();
911
+ const animatedTextureManager = new AnimatedTextureManager();
712
912
  const load = (path) => textureLoader.loadAsync("https://static.enchor.us/" + path);
913
+ const loadAnimated = async (path) => {
914
+ const url = `https://static.enchor.us/preview-${path}.webp`;
915
+ if (!animationsEnabled) {
916
+ return loadStaticTexture(url);
917
+ }
918
+ const result = await AnimatedTexture.create(url);
919
+ if (result instanceof AnimatedTexture) {
920
+ animatedTextureManager.register(result);
921
+ return result.texture;
922
+ }
923
+ return result;
924
+ };
713
925
  const [highwayTexture, strikelineTexture, noteTextures] = await Promise.all([
714
926
  (async () => {
715
927
  const texture = await load("preview-highway.png");
@@ -731,9 +943,7 @@ async function loadTextures(instrumentType) {
731
943
  (async () => {
732
944
  const texturePromises = [];
733
945
  const addTexture = (type, flags, path) => {
734
- const texture = textureLoader.loadAsync(
735
- `https://static.enchor.us/preview-${path}.webp`
736
- );
946
+ const texture = loadAnimated(path);
737
947
  texturePromises.push({ type, flags, texture });
738
948
  return texture;
739
949
  };
@@ -898,7 +1108,8 @@ async function loadTextures(instrumentType) {
898
1108
  return {
899
1109
  highwayTexture,
900
1110
  strikelineTexture,
901
- noteTextures
1111
+ noteTextures,
1112
+ animatedTextureManager
902
1113
  };
903
1114
  }
904
1115
  function adjustParsedChart(parsedChart, instrument, difficulty) {
@@ -1149,7 +1360,7 @@ function isVideoFile(fileName) {
1149
1360
  const ext = fileName.toLowerCase().slice(fileName.lastIndexOf("."));
1150
1361
  return videoExtensions.includes(ext);
1151
1362
  }
1152
- async function prepareChartData(files, instrument, difficulty, initialSeekPercent = 0) {
1363
+ async function prepareChartData(files, instrument, difficulty, initialSeekPercent = 0, textureOptions = {}) {
1153
1364
  const filteredFiles = files.filter((f) => !isVideoFile(f.fileName));
1154
1365
  const scannedChart = scanChartFolder(filteredFiles);
1155
1366
  if (!scannedChart.playable || !scannedChart.notesData) {
@@ -1184,7 +1395,10 @@ async function prepareChartData(files, instrument, difficulty, initialSeekPercen
1184
1395
  }
1185
1396
  const audioFiles = findAudioFiles(filteredFiles);
1186
1397
  const instrumentType = getInstrumentType2(instrument);
1187
- const textures = await ChartPreview.loadTextures(instrumentType);
1398
+ const textures = await ChartPreview.loadTextures(
1399
+ instrumentType,
1400
+ textureOptions
1401
+ );
1188
1402
  const lastNote = parsedChart.trackData.flatMap((t) => t.noteEventGroups.flat()).reduce((max, note) => Math.max(max, note.msTime + note.msLength), 0);
1189
1403
  const audioLengthMs = scannedChart.song_length || lastNote + 5e3;
1190
1404
  const startDelayMs = (scannedChart.delay || 0) + (scannedChart.chart_offset || 0) * 1e3;
@@ -1505,10 +1719,11 @@ var ChartPreviewPlayer = class extends HTMLElement {
1505
1719
  }
1506
1720
  };
1507
1721
  this.handleFullscreenChange = () => {
1508
- this._isFullscreen = document.fullscreenElement === this.container;
1722
+ const fullscreenElement = document.fullscreenElement || document.webkitFullscreenElement;
1723
+ this._isFullscreen = fullscreenElement !== null && fullscreenElement !== void 0;
1509
1724
  this.container.classList.toggle("fullscreen", this._isFullscreen);
1510
1725
  this.fullscreenBtn.innerHTML = this._isFullscreen ? ICONS.fullscreenExit : ICONS.fullscreen;
1511
- this.fullscreenBtn.title = this._isFullscreen ? "Exit fullscreen" : "Toggle fullscreen";
1726
+ this.fullscreenBtn.title = this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
1512
1727
  };
1513
1728
  this.shadow = this.attachShadow({ mode: "open" });
1514
1729
  this.render();
@@ -1533,6 +1748,10 @@ var ChartPreviewPlayer = class extends HTMLElement {
1533
1748
  connectedCallback() {
1534
1749
  this.setAttribute("tabindex", "0");
1535
1750
  document.addEventListener("fullscreenchange", this.handleFullscreenChange);
1751
+ document.addEventListener(
1752
+ "webkitfullscreenchange",
1753
+ this.handleFullscreenChange
1754
+ );
1536
1755
  }
1537
1756
  disconnectedCallback() {
1538
1757
  this.dispose();
@@ -1540,6 +1759,10 @@ var ChartPreviewPlayer = class extends HTMLElement {
1540
1759
  "fullscreenchange",
1541
1760
  this.handleFullscreenChange
1542
1761
  );
1762
+ document.removeEventListener(
1763
+ "webkitfullscreenchange",
1764
+ this.handleFullscreenChange
1765
+ );
1543
1766
  this.removeEventListener("keydown", this.handleKeydown);
1544
1767
  }
1545
1768
  render() {
@@ -1620,7 +1843,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
1620
1843
  this.onSeekEnd();
1621
1844
  }
1622
1845
  });
1623
- this.fullscreenBtn.addEventListener("click", () => {
1846
+ this.fullscreenBtn.addEventListener("click", (e) => {
1847
+ e.stopPropagation();
1624
1848
  this.toggleFullscreen();
1625
1849
  });
1626
1850
  this.addEventListener("keydown", this.handleKeydown);
@@ -1933,7 +2157,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
1933
2157
  files,
1934
2158
  config.instrument,
1935
2159
  config.difficulty,
1936
- config.initialSeekPercent
2160
+ config.initialSeekPercent,
2161
+ { animationsEnabled: config.animationsEnabled }
1937
2162
  );
1938
2163
  await this.loadChart({
1939
2164
  parsedChart: preparedData.parsedChart,
@@ -1975,7 +2200,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
1975
2200
  files,
1976
2201
  config.instrument,
1977
2202
  config.difficulty,
1978
- config.initialSeekPercent
2203
+ config.initialSeekPercent,
2204
+ { animationsEnabled: config.animationsEnabled }
1979
2205
  );
1980
2206
  await this.loadChart({
1981
2207
  parsedChart: preparedData.parsedChart,
@@ -2014,7 +2240,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
2014
2240
  config.files,
2015
2241
  config.instrument,
2016
2242
  config.difficulty,
2017
- config.initialSeekPercent
2243
+ config.initialSeekPercent,
2244
+ { animationsEnabled: config.animationsEnabled }
2018
2245
  );
2019
2246
  await this.loadChart({
2020
2247
  parsedChart: preparedData.parsedChart,
@@ -2114,12 +2341,28 @@ var ChartPreviewPlayer = class extends HTMLElement {
2114
2341
  * Toggle fullscreen mode
2115
2342
  */
2116
2343
  async toggleFullscreen() {
2117
- if (!document.fullscreenEnabled)
2344
+ const doc = document;
2345
+ const container = this.container;
2346
+ const isFullscreenEnabled = doc.fullscreenEnabled || doc.webkitFullscreenEnabled;
2347
+ if (!isFullscreenEnabled)
2118
2348
  return;
2119
- if (this._isFullscreen) {
2120
- await document.exitFullscreen();
2121
- } else {
2122
- 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);
2123
2366
  }
2124
2367
  }
2125
2368
  /**
@@ -2154,6 +2397,7 @@ import {
2154
2397
  export {
2155
2398
  ChartPreview,
2156
2399
  ChartPreviewPlayer,
2400
+ areAnimationsSupported,
2157
2401
  extractSngFile,
2158
2402
  fetchSngFile,
2159
2403
  findAudioFiles,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chart-preview",
3
- "version": "1.0.3",
3
+ "version": "1.2.0",
4
4
  "author": "Geo",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",