chart-preview 1.1.0 → 1.3.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 +12 -0
- package/README.md +34 -4
- package/dist/index.d.ts +50 -7
- package/dist/index.js +153 -47
- package/dist/index.mjs +152 -47
- package/package.json +1 -1
- package/src/ChartPreview.ts +156 -38
- package/src/ChartPreviewPlayer.ts +77 -12
- package/src/SngLoader.ts +27 -3
- package/src/index.ts +2 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
149
|
+
* This method is SYNCHRONOUS for optimal render performance - all frames
|
|
150
|
+
* are pre-decoded during initialization.
|
|
137
151
|
*/
|
|
138
|
-
|
|
139
|
-
if (!this.isAnimated ||
|
|
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
|
-
|
|
149
|
-
|
|
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(
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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) {
|
|
@@ -332,6 +369,17 @@ var _ChartPreview = class {
|
|
|
332
369
|
get chartEndTimeMs() {
|
|
333
370
|
return this.audioManager.chartEndTimeMs;
|
|
334
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* Manually triggers a resize of the camera and renderer.
|
|
374
|
+
* Call this when the container size changes (e.g., entering/exiting fullscreen).
|
|
375
|
+
*/
|
|
376
|
+
resize() {
|
|
377
|
+
this.camera.triggerResize();
|
|
378
|
+
this.renderer.triggerResize();
|
|
379
|
+
if (this.paused) {
|
|
380
|
+
this.animateFrame(false);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
335
383
|
/**
|
|
336
384
|
* Cleans up all resources. Call this when discarding the preview.
|
|
337
385
|
* The preview should not be used after calling dispose().
|
|
@@ -419,6 +467,10 @@ var ChartCamera = class extends THREE.PerspectiveCamera {
|
|
|
419
467
|
this.aspect = width / height;
|
|
420
468
|
this.updateProjectionMatrix();
|
|
421
469
|
}
|
|
470
|
+
/** Manually trigger a resize update */
|
|
471
|
+
triggerResize() {
|
|
472
|
+
this.onResize();
|
|
473
|
+
}
|
|
422
474
|
dispose() {
|
|
423
475
|
window.removeEventListener("resize", this.resizeListener);
|
|
424
476
|
this.clear();
|
|
@@ -441,6 +493,10 @@ var ChartRenderer = class extends THREE.WebGLRenderer {
|
|
|
441
493
|
const height = this.divContainer.offsetHeight ?? window.innerHeight;
|
|
442
494
|
this.setSize(width, height);
|
|
443
495
|
}
|
|
496
|
+
/** Manually trigger a resize update */
|
|
497
|
+
triggerResize() {
|
|
498
|
+
this.onResize();
|
|
499
|
+
}
|
|
444
500
|
dispose() {
|
|
445
501
|
window.removeEventListener("resize", this.resizeListener);
|
|
446
502
|
super.dispose();
|
|
@@ -782,11 +838,16 @@ var NotesManager = class {
|
|
|
782
838
|
}
|
|
783
839
|
for (let i = maxNoteEventIndex + 1; this.noteEvents[i] && this.noteEvents[i].msTime < renderEndTimeMs; i++) {
|
|
784
840
|
const note = this.noteEvents[i];
|
|
841
|
+
const material = this.noteMaterials.get(note.type)?.get(note.flags);
|
|
842
|
+
if (!material) {
|
|
843
|
+
console.warn(
|
|
844
|
+
`Missing texture for note type ${note.type} with flags ${note.flags}`
|
|
845
|
+
);
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
785
848
|
const noteGroup = new THREE.Group();
|
|
786
849
|
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
|
-
);
|
|
850
|
+
const sprite = new THREE.Sprite(material);
|
|
790
851
|
noteGroup.add(sprite);
|
|
791
852
|
sprite.center = new THREE.Vector2(
|
|
792
853
|
note.type === noteTypes.kick ? 0.62 : 0.5,
|
|
@@ -863,12 +924,16 @@ var EventSequence = class {
|
|
|
863
924
|
return earliestActiveEventIndex === null ? this.lastPrecedingEventIndex + 1 : earliestActiveEventIndex;
|
|
864
925
|
}
|
|
865
926
|
};
|
|
866
|
-
async function loadTextures(instrumentType) {
|
|
927
|
+
async function loadTextures(instrumentType, options = {}) {
|
|
928
|
+
const { animationsEnabled = true } = options;
|
|
867
929
|
const textureLoader = new THREE.TextureLoader();
|
|
868
930
|
const animatedTextureManager = new AnimatedTextureManager();
|
|
869
931
|
const load = (path) => textureLoader.loadAsync("https://static.enchor.us/" + path);
|
|
870
932
|
const loadAnimated = async (path) => {
|
|
871
933
|
const url = `https://static.enchor.us/preview-${path}.webp`;
|
|
934
|
+
if (!animationsEnabled) {
|
|
935
|
+
return loadStaticTexture(url);
|
|
936
|
+
}
|
|
872
937
|
const result = await AnimatedTexture.create(url);
|
|
873
938
|
if (result instanceof AnimatedTexture) {
|
|
874
939
|
animatedTextureManager.register(result);
|
|
@@ -1314,7 +1379,7 @@ function isVideoFile(fileName) {
|
|
|
1314
1379
|
const ext = fileName.toLowerCase().slice(fileName.lastIndexOf("."));
|
|
1315
1380
|
return videoExtensions.includes(ext);
|
|
1316
1381
|
}
|
|
1317
|
-
async function prepareChartData(files, instrument, difficulty, initialSeekPercent = 0) {
|
|
1382
|
+
async function prepareChartData(files, instrument, difficulty, initialSeekPercent = 0, textureOptions = {}) {
|
|
1318
1383
|
const filteredFiles = files.filter((f) => !isVideoFile(f.fileName));
|
|
1319
1384
|
const scannedChart = scanChartFolder(filteredFiles);
|
|
1320
1385
|
if (!scannedChart.playable || !scannedChart.notesData) {
|
|
@@ -1349,7 +1414,10 @@ async function prepareChartData(files, instrument, difficulty, initialSeekPercen
|
|
|
1349
1414
|
}
|
|
1350
1415
|
const audioFiles = findAudioFiles(filteredFiles);
|
|
1351
1416
|
const instrumentType = getInstrumentType2(instrument);
|
|
1352
|
-
const textures = await ChartPreview.loadTextures(
|
|
1417
|
+
const textures = await ChartPreview.loadTextures(
|
|
1418
|
+
instrumentType,
|
|
1419
|
+
textureOptions
|
|
1420
|
+
);
|
|
1353
1421
|
const lastNote = parsedChart.trackData.flatMap((t) => t.noteEventGroups.flat()).reduce((max, note) => Math.max(max, note.msTime + note.msLength), 0);
|
|
1354
1422
|
const audioLengthMs = scannedChart.song_length || lastNote + 5e3;
|
|
1355
1423
|
const startDelayMs = (scannedChart.delay || 0) + (scannedChart.chart_offset || 0) * 1e3;
|
|
@@ -1670,10 +1738,18 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
1670
1738
|
}
|
|
1671
1739
|
};
|
|
1672
1740
|
this.handleFullscreenChange = () => {
|
|
1673
|
-
|
|
1741
|
+
const fullscreenElement = document.fullscreenElement || document.webkitFullscreenElement;
|
|
1742
|
+
this._isFullscreen = fullscreenElement !== null && fullscreenElement !== void 0;
|
|
1674
1743
|
this.container.classList.toggle("fullscreen", this._isFullscreen);
|
|
1675
1744
|
this.fullscreenBtn.innerHTML = this._isFullscreen ? ICONS.fullscreenExit : ICONS.fullscreen;
|
|
1676
|
-
this.fullscreenBtn.title = this._isFullscreen ? "Exit fullscreen" : "
|
|
1745
|
+
this.fullscreenBtn.title = this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
|
|
1746
|
+
if (this.chartPreview) {
|
|
1747
|
+
requestAnimationFrame(() => {
|
|
1748
|
+
requestAnimationFrame(() => {
|
|
1749
|
+
this.chartPreview?.resize();
|
|
1750
|
+
});
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1677
1753
|
};
|
|
1678
1754
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
1679
1755
|
this.render();
|
|
@@ -1698,6 +1774,10 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
1698
1774
|
connectedCallback() {
|
|
1699
1775
|
this.setAttribute("tabindex", "0");
|
|
1700
1776
|
document.addEventListener("fullscreenchange", this.handleFullscreenChange);
|
|
1777
|
+
document.addEventListener(
|
|
1778
|
+
"webkitfullscreenchange",
|
|
1779
|
+
this.handleFullscreenChange
|
|
1780
|
+
);
|
|
1701
1781
|
}
|
|
1702
1782
|
disconnectedCallback() {
|
|
1703
1783
|
this.dispose();
|
|
@@ -1705,6 +1785,10 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
1705
1785
|
"fullscreenchange",
|
|
1706
1786
|
this.handleFullscreenChange
|
|
1707
1787
|
);
|
|
1788
|
+
document.removeEventListener(
|
|
1789
|
+
"webkitfullscreenchange",
|
|
1790
|
+
this.handleFullscreenChange
|
|
1791
|
+
);
|
|
1708
1792
|
this.removeEventListener("keydown", this.handleKeydown);
|
|
1709
1793
|
}
|
|
1710
1794
|
render() {
|
|
@@ -1785,7 +1869,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
1785
1869
|
this.onSeekEnd();
|
|
1786
1870
|
}
|
|
1787
1871
|
});
|
|
1788
|
-
this.fullscreenBtn.addEventListener("click", () => {
|
|
1872
|
+
this.fullscreenBtn.addEventListener("click", (e) => {
|
|
1873
|
+
e.stopPropagation();
|
|
1789
1874
|
this.toggleFullscreen();
|
|
1790
1875
|
});
|
|
1791
1876
|
this.addEventListener("keydown", this.handleKeydown);
|
|
@@ -2098,7 +2183,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
2098
2183
|
files,
|
|
2099
2184
|
config.instrument,
|
|
2100
2185
|
config.difficulty,
|
|
2101
|
-
config.initialSeekPercent
|
|
2186
|
+
config.initialSeekPercent,
|
|
2187
|
+
{ animationsEnabled: config.animationsEnabled }
|
|
2102
2188
|
);
|
|
2103
2189
|
await this.loadChart({
|
|
2104
2190
|
parsedChart: preparedData.parsedChart,
|
|
@@ -2140,7 +2226,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
2140
2226
|
files,
|
|
2141
2227
|
config.instrument,
|
|
2142
2228
|
config.difficulty,
|
|
2143
|
-
config.initialSeekPercent
|
|
2229
|
+
config.initialSeekPercent,
|
|
2230
|
+
{ animationsEnabled: config.animationsEnabled }
|
|
2144
2231
|
);
|
|
2145
2232
|
await this.loadChart({
|
|
2146
2233
|
parsedChart: preparedData.parsedChart,
|
|
@@ -2179,7 +2266,8 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
2179
2266
|
config.files,
|
|
2180
2267
|
config.instrument,
|
|
2181
2268
|
config.difficulty,
|
|
2182
|
-
config.initialSeekPercent
|
|
2269
|
+
config.initialSeekPercent,
|
|
2270
|
+
{ animationsEnabled: config.animationsEnabled }
|
|
2183
2271
|
);
|
|
2184
2272
|
await this.loadChart({
|
|
2185
2273
|
parsedChart: preparedData.parsedChart,
|
|
@@ -2279,12 +2367,28 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
2279
2367
|
* Toggle fullscreen mode
|
|
2280
2368
|
*/
|
|
2281
2369
|
async toggleFullscreen() {
|
|
2282
|
-
|
|
2370
|
+
const doc = document;
|
|
2371
|
+
const container = this.container;
|
|
2372
|
+
const isFullscreenEnabled = doc.fullscreenEnabled || doc.webkitFullscreenEnabled;
|
|
2373
|
+
if (!isFullscreenEnabled)
|
|
2283
2374
|
return;
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2375
|
+
const fullscreenElement = doc.fullscreenElement || doc.webkitFullscreenElement;
|
|
2376
|
+
try {
|
|
2377
|
+
if (fullscreenElement) {
|
|
2378
|
+
if (doc.exitFullscreen) {
|
|
2379
|
+
await doc.exitFullscreen();
|
|
2380
|
+
} else if (doc.webkitExitFullscreen) {
|
|
2381
|
+
doc.webkitExitFullscreen();
|
|
2382
|
+
}
|
|
2383
|
+
} else {
|
|
2384
|
+
if (container.requestFullscreen) {
|
|
2385
|
+
await container.requestFullscreen();
|
|
2386
|
+
} else if (container.webkitRequestFullscreen) {
|
|
2387
|
+
container.webkitRequestFullscreen();
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
} catch (error) {
|
|
2391
|
+
console.warn("Fullscreen toggle failed:", error);
|
|
2288
2392
|
}
|
|
2289
2393
|
}
|
|
2290
2394
|
/**
|
|
@@ -2319,6 +2423,7 @@ import {
|
|
|
2319
2423
|
export {
|
|
2320
2424
|
ChartPreview,
|
|
2321
2425
|
ChartPreviewPlayer,
|
|
2426
|
+
areAnimationsSupported,
|
|
2322
2427
|
extractSngFile,
|
|
2323
2428
|
fetchSngFile,
|
|
2324
2429
|
findAudioFiles,
|