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/CHANGELOG.md +6 -0
- package/README.md +34 -4
- package/dist/index.d.ts +45 -7
- package/dist/index.js +127 -47
- package/dist/index.mjs +126 -47
- package/package.json +1 -1
- package/src/ChartPreview.ts +133 -38
- package/src/ChartPreviewPlayer.ts +65 -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) {
|
|
@@ -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(
|
|
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
|
-
|
|
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" : "
|
|
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
|
-
|
|
2344
|
+
const doc = document;
|
|
2345
|
+
const container = this.container;
|
|
2346
|
+
const isFullscreenEnabled = doc.fullscreenEnabled || doc.webkitFullscreenEnabled;
|
|
2347
|
+
if (!isFullscreenEnabled)
|
|
2283
2348
|
return;
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
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
package/src/ChartPreview.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = (
|
|
147
|
+
const durationMs = (videoFrame.duration ?? 100000) / 1000;
|
|
137
148
|
animTexture.frameDurations.push(durationMs);
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
188
|
+
* This method is SYNCHRONOUS for optimal render performance - all frames
|
|
189
|
+
* are pre-decoded during initialization.
|
|
162
190
|
*/
|
|
163
|
-
|
|
164
|
-
if (!this.isAnimated ||
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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(
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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(
|
|
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) {
|