chart-preview 1.0.2 → 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 +12 -0
- package/dist/index.d.ts +41 -1
- package/dist/index.js +170 -5
- package/dist/index.mjs +170 -5
- package/package.json +1 -1
- package/src/ChartPreview.ts +225 -4
- package/src/ChartPreviewPlayer.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# chart-preview
|
|
2
2
|
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 885a2db: Add note animations (Chrome only)
|
|
8
|
+
|
|
9
|
+
## 1.0.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 467db29: Enable play button in ChartPreviewPlayer by removing the disabled attribute, allowing user interaction.
|
|
14
|
+
|
|
3
15
|
## 1.0.2
|
|
4
16
|
|
|
5
17
|
### 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
|
|
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 =
|
|
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) {
|
|
@@ -1584,7 +1749,7 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
1584
1749
|
<div class="preview-viewport">
|
|
1585
1750
|
</div>
|
|
1586
1751
|
<div class="controls">
|
|
1587
|
-
<button class="control-btn play-btn" aria-label="Play"
|
|
1752
|
+
<button class="control-btn play-btn" aria-label="Play">
|
|
1588
1753
|
${ICONS.play}
|
|
1589
1754
|
</button>
|
|
1590
1755
|
<div class="volume-wrapper">
|
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 =
|
|
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) {
|
|
@@ -1549,7 +1714,7 @@ var ChartPreviewPlayer = class extends HTMLElement {
|
|
|
1549
1714
|
<div class="preview-viewport">
|
|
1550
1715
|
</div>
|
|
1551
1716
|
<div class="controls">
|
|
1552
|
-
<button class="control-btn play-btn" aria-label="Play"
|
|
1717
|
+
<button class="control-btn play-btn" aria-label="Play">
|
|
1553
1718
|
${ICONS.play}
|
|
1554
1719
|
</button>
|
|
1555
1720
|
<div class="volume-wrapper">
|
package/package.json
CHANGED
package/src/ChartPreview.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
|
@@ -375,7 +375,7 @@ export class ChartPreviewPlayer extends HTMLElement {
|
|
|
375
375
|
<div class="preview-viewport">
|
|
376
376
|
</div>
|
|
377
377
|
<div class="controls">
|
|
378
|
-
<button class="control-btn play-btn" aria-label="Play"
|
|
378
|
+
<button class="control-btn play-btn" aria-label="Play">
|
|
379
379
|
${ICONS.play}
|
|
380
380
|
</button>
|
|
381
381
|
<div class="volume-wrapper">
|