libbitsub 1.0.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/LICENSE +674 -0
- package/README.md +355 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/ts/parsers.d.ts +156 -0
- package/dist/ts/parsers.d.ts.map +1 -0
- package/dist/ts/parsers.js +366 -0
- package/dist/ts/parsers.js.map +1 -0
- package/dist/ts/renderers.d.ts +155 -0
- package/dist/ts/renderers.d.ts.map +1 -0
- package/dist/ts/renderers.js +640 -0
- package/dist/ts/renderers.js.map +1 -0
- package/dist/ts/types.d.ts +146 -0
- package/dist/ts/types.d.ts.map +1 -0
- package/dist/ts/types.js +5 -0
- package/dist/ts/types.js.map +1 -0
- package/dist/ts/utils.d.ts +11 -0
- package/dist/ts/utils.d.ts.map +1 -0
- package/dist/ts/utils.js +47 -0
- package/dist/ts/utils.js.map +1 -0
- package/dist/ts/wasm.d.ts +15 -0
- package/dist/ts/wasm.d.ts.map +1 -0
- package/dist/ts/wasm.js +54 -0
- package/dist/ts/wasm.js.map +1 -0
- package/dist/ts/worker.d.ts +12 -0
- package/dist/ts/worker.d.ts.map +1 -0
- package/dist/ts/worker.js +342 -0
- package/dist/ts/worker.js.map +1 -0
- package/dist/wrapper.d.ts +17 -0
- package/dist/wrapper.d.ts.map +1 -0
- package/dist/wrapper.js +22 -0
- package/dist/wrapper.js.map +1 -0
- package/package.json +54 -0
- package/pkg/LICENSE +674 -0
- package/pkg/README.md +355 -0
- package/pkg/libbitsub.d.ts +323 -0
- package/pkg/libbitsub.js +880 -0
- package/pkg/libbitsub_bg.wasm +0 -0
- package/pkg/libbitsub_bg.wasm.d.ts +68 -0
- package/pkg/package.json +31 -0
- package/src/wrapper.ts +41 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level video-integrated subtitle renderers for libbitsub.
|
|
3
|
+
* Handles canvas overlay, video sync, and subtitle fetching.
|
|
4
|
+
*/
|
|
5
|
+
import { initWasm } from './wasm';
|
|
6
|
+
import { getOrCreateWorker, sendToWorker } from './worker';
|
|
7
|
+
import { binarySearchTimestamp, convertFrameData, createWorkerState } from './utils';
|
|
8
|
+
import { PgsParser, VobSubParserLowLevel } from './parsers';
|
|
9
|
+
/** Default display settings */
|
|
10
|
+
const DEFAULT_DISPLAY_SETTINGS = {
|
|
11
|
+
scale: 1.0,
|
|
12
|
+
verticalOffset: 0
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Base class for video-integrated subtitle renderers.
|
|
16
|
+
* Handles canvas overlay, video sync, and subtitle fetching.
|
|
17
|
+
*/
|
|
18
|
+
class BaseVideoSubtitleRenderer {
|
|
19
|
+
video;
|
|
20
|
+
subUrl;
|
|
21
|
+
canvas = null;
|
|
22
|
+
ctx = null;
|
|
23
|
+
animationFrameId = null;
|
|
24
|
+
isLoaded = false;
|
|
25
|
+
lastRenderedIndex = -1;
|
|
26
|
+
lastRenderedTime = -1;
|
|
27
|
+
disposed = false;
|
|
28
|
+
resizeObserver = null;
|
|
29
|
+
tempCanvas = null;
|
|
30
|
+
tempCtx = null;
|
|
31
|
+
lastRenderedData = null;
|
|
32
|
+
/** Display settings for subtitle rendering */
|
|
33
|
+
displaySettings = { ...DEFAULT_DISPLAY_SETTINGS };
|
|
34
|
+
// Performance tracking
|
|
35
|
+
perfStats = {
|
|
36
|
+
framesRendered: 0,
|
|
37
|
+
framesDropped: 0,
|
|
38
|
+
renderTimes: [],
|
|
39
|
+
lastRenderTime: 0,
|
|
40
|
+
fpsTimestamps: [],
|
|
41
|
+
lastFrameTime: 0
|
|
42
|
+
};
|
|
43
|
+
constructor(options) {
|
|
44
|
+
this.video = options.video;
|
|
45
|
+
this.subUrl = options.subUrl;
|
|
46
|
+
}
|
|
47
|
+
/** Get current display settings */
|
|
48
|
+
getDisplaySettings() {
|
|
49
|
+
return { ...this.displaySettings };
|
|
50
|
+
}
|
|
51
|
+
/** Get base stats common to all renderers */
|
|
52
|
+
getBaseStats() {
|
|
53
|
+
const now = performance.now();
|
|
54
|
+
// Clean up old FPS timestamps (keep last second)
|
|
55
|
+
this.perfStats.fpsTimestamps = this.perfStats.fpsTimestamps.filter((t) => now - t < 1000);
|
|
56
|
+
const renderTimes = this.perfStats.renderTimes;
|
|
57
|
+
const avgRenderTime = renderTimes.length > 0 ? renderTimes.reduce((a, b) => a + b, 0) / renderTimes.length : 0;
|
|
58
|
+
const maxRenderTime = renderTimes.length > 0 ? Math.max(...renderTimes) : 0;
|
|
59
|
+
const minRenderTime = renderTimes.length > 0 ? Math.min(...renderTimes) : 0;
|
|
60
|
+
return {
|
|
61
|
+
framesRendered: this.perfStats.framesRendered,
|
|
62
|
+
framesDropped: this.perfStats.framesDropped,
|
|
63
|
+
avgRenderTime: Math.round(avgRenderTime * 100) / 100,
|
|
64
|
+
maxRenderTime: Math.round(maxRenderTime * 100) / 100,
|
|
65
|
+
minRenderTime: Math.round(minRenderTime * 100) / 100,
|
|
66
|
+
lastRenderTime: Math.round(this.perfStats.lastRenderTime * 100) / 100,
|
|
67
|
+
renderFps: this.perfStats.fpsTimestamps.length,
|
|
68
|
+
currentIndex: this.lastRenderedIndex
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Set display settings and force re-render */
|
|
72
|
+
setDisplaySettings(settings) {
|
|
73
|
+
const changed = settings.scale !== this.displaySettings.scale || settings.verticalOffset !== this.displaySettings.verticalOffset;
|
|
74
|
+
if (settings.scale !== undefined) {
|
|
75
|
+
this.displaySettings.scale = Math.max(0.1, Math.min(3.0, settings.scale));
|
|
76
|
+
}
|
|
77
|
+
if (settings.verticalOffset !== undefined) {
|
|
78
|
+
this.displaySettings.verticalOffset = Math.max(-50, Math.min(50, settings.verticalOffset));
|
|
79
|
+
}
|
|
80
|
+
// Force re-render if settings changed
|
|
81
|
+
if (changed) {
|
|
82
|
+
this.lastRenderedIndex = -1;
|
|
83
|
+
this.lastRenderedTime = -1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Reset display settings to defaults */
|
|
87
|
+
resetDisplaySettings() {
|
|
88
|
+
this.displaySettings = { ...DEFAULT_DISPLAY_SETTINGS };
|
|
89
|
+
this.lastRenderedIndex = -1;
|
|
90
|
+
this.lastRenderedTime = -1;
|
|
91
|
+
}
|
|
92
|
+
/** Start initialization. */
|
|
93
|
+
startInit() {
|
|
94
|
+
this.init();
|
|
95
|
+
}
|
|
96
|
+
/** Initialize the renderer. */
|
|
97
|
+
async init() {
|
|
98
|
+
await initWasm();
|
|
99
|
+
this.createCanvas();
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
101
|
+
await this.loadSubtitles();
|
|
102
|
+
this.startRenderLoop();
|
|
103
|
+
}
|
|
104
|
+
/** Create the canvas overlay positioned over the video. */
|
|
105
|
+
createCanvas() {
|
|
106
|
+
this.canvas = document.createElement('canvas');
|
|
107
|
+
Object.assign(this.canvas.style, {
|
|
108
|
+
position: 'absolute',
|
|
109
|
+
pointerEvents: 'none',
|
|
110
|
+
zIndex: '10'
|
|
111
|
+
});
|
|
112
|
+
const parent = this.video.parentElement;
|
|
113
|
+
if (parent) {
|
|
114
|
+
if (window.getComputedStyle(parent).position === 'static') {
|
|
115
|
+
parent.style.position = 'relative';
|
|
116
|
+
}
|
|
117
|
+
parent.appendChild(this.canvas);
|
|
118
|
+
}
|
|
119
|
+
this.ctx = this.canvas.getContext('2d');
|
|
120
|
+
this.updateCanvasSize();
|
|
121
|
+
this.resizeObserver = new ResizeObserver(() => this.updateCanvasSize());
|
|
122
|
+
this.resizeObserver.observe(this.video);
|
|
123
|
+
this.video.addEventListener('loadedmetadata', () => this.updateCanvasSize());
|
|
124
|
+
this.video.addEventListener('seeked', () => {
|
|
125
|
+
this.lastRenderedIndex = -1;
|
|
126
|
+
this.lastRenderedTime = -1;
|
|
127
|
+
this.onSeek();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** Called when video seeks. */
|
|
131
|
+
onSeek() { }
|
|
132
|
+
/** Calculate the actual video content bounds, accounting for letterboxing/pillarboxing */
|
|
133
|
+
getVideoContentBounds() {
|
|
134
|
+
const rect = this.video.getBoundingClientRect();
|
|
135
|
+
const videoWidth = this.video.videoWidth || rect.width;
|
|
136
|
+
const videoHeight = this.video.videoHeight || rect.height;
|
|
137
|
+
// Calculate aspect ratios
|
|
138
|
+
const elementAspect = rect.width / rect.height;
|
|
139
|
+
const videoAspect = videoWidth / videoHeight;
|
|
140
|
+
let contentWidth;
|
|
141
|
+
let contentHeight;
|
|
142
|
+
let contentX;
|
|
143
|
+
let contentY;
|
|
144
|
+
if (Math.abs(elementAspect - videoAspect) < 0.01) {
|
|
145
|
+
// Aspect ratios match - video fills the element
|
|
146
|
+
contentWidth = rect.width;
|
|
147
|
+
contentHeight = rect.height;
|
|
148
|
+
contentX = 0;
|
|
149
|
+
contentY = 0;
|
|
150
|
+
}
|
|
151
|
+
else if (elementAspect > videoAspect) {
|
|
152
|
+
// Element is wider than video - pillarboxing (black bars on sides)
|
|
153
|
+
contentHeight = rect.height;
|
|
154
|
+
contentWidth = rect.height * videoAspect;
|
|
155
|
+
contentX = (rect.width - contentWidth) / 2;
|
|
156
|
+
contentY = 0;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
// Element is taller than video - letterboxing (black bars top/bottom)
|
|
160
|
+
contentWidth = rect.width;
|
|
161
|
+
contentHeight = rect.width / videoAspect;
|
|
162
|
+
contentX = 0;
|
|
163
|
+
contentY = (rect.height - contentHeight) / 2;
|
|
164
|
+
}
|
|
165
|
+
return { x: contentX, y: contentY, width: contentWidth, height: contentHeight };
|
|
166
|
+
}
|
|
167
|
+
/** Update canvas size to match video content area. */
|
|
168
|
+
updateCanvasSize() {
|
|
169
|
+
if (!this.canvas)
|
|
170
|
+
return;
|
|
171
|
+
const bounds = this.getVideoContentBounds();
|
|
172
|
+
const width = bounds.width > 0 ? bounds.width : this.video.videoWidth || 1920;
|
|
173
|
+
const height = bounds.height > 0 ? bounds.height : this.video.videoHeight || 1080;
|
|
174
|
+
this.canvas.width = width * window.devicePixelRatio;
|
|
175
|
+
this.canvas.height = height * window.devicePixelRatio;
|
|
176
|
+
// Position canvas to match video content area
|
|
177
|
+
this.canvas.style.left = `${bounds.x}px`;
|
|
178
|
+
this.canvas.style.top = `${bounds.y}px`;
|
|
179
|
+
this.canvas.style.width = `${bounds.width}px`;
|
|
180
|
+
this.canvas.style.height = `${bounds.height}px`;
|
|
181
|
+
this.lastRenderedIndex = -1;
|
|
182
|
+
this.lastRenderedTime = -1;
|
|
183
|
+
}
|
|
184
|
+
/** Start the render loop. */
|
|
185
|
+
startRenderLoop() {
|
|
186
|
+
// Create reusable temp canvas for rendering
|
|
187
|
+
this.tempCanvas = document.createElement('canvas');
|
|
188
|
+
this.tempCtx = this.tempCanvas.getContext('2d');
|
|
189
|
+
const render = () => {
|
|
190
|
+
if (this.disposed)
|
|
191
|
+
return;
|
|
192
|
+
if (this.isLoaded) {
|
|
193
|
+
const currentTime = this.video.currentTime;
|
|
194
|
+
const currentIndex = this.findCurrentIndex(currentTime);
|
|
195
|
+
// Only re-render if index changed
|
|
196
|
+
if (currentIndex !== this.lastRenderedIndex) {
|
|
197
|
+
const startTime = performance.now();
|
|
198
|
+
this.renderFrame(currentTime, currentIndex);
|
|
199
|
+
const endTime = performance.now();
|
|
200
|
+
// Track performance
|
|
201
|
+
const renderTime = endTime - startTime;
|
|
202
|
+
this.perfStats.lastRenderTime = renderTime;
|
|
203
|
+
this.perfStats.renderTimes.push(renderTime);
|
|
204
|
+
// Keep only last 60 samples for rolling average
|
|
205
|
+
if (this.perfStats.renderTimes.length > 60) {
|
|
206
|
+
this.perfStats.renderTimes.shift();
|
|
207
|
+
}
|
|
208
|
+
this.perfStats.framesRendered++;
|
|
209
|
+
this.perfStats.fpsTimestamps.push(endTime);
|
|
210
|
+
// Check for frame drop (if render took longer than frame budget ~16.67ms for 60fps)
|
|
211
|
+
const frameBudget = 16.67;
|
|
212
|
+
if (renderTime > frameBudget) {
|
|
213
|
+
this.perfStats.framesDropped++;
|
|
214
|
+
}
|
|
215
|
+
this.lastRenderedIndex = currentIndex;
|
|
216
|
+
this.lastRenderedTime = currentTime;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
this.animationFrameId = requestAnimationFrame(render);
|
|
220
|
+
};
|
|
221
|
+
this.animationFrameId = requestAnimationFrame(render);
|
|
222
|
+
}
|
|
223
|
+
/** Render a subtitle frame to the canvas. */
|
|
224
|
+
renderFrame(time, index) {
|
|
225
|
+
if (!this.ctx || !this.canvas)
|
|
226
|
+
return;
|
|
227
|
+
// Get the data for this index
|
|
228
|
+
const data = index >= 0 ? this.renderAtIndex(index) : undefined;
|
|
229
|
+
// If data is undefined, it means async loading is in progress
|
|
230
|
+
// Keep showing the last frame only while waiting for async data
|
|
231
|
+
// Note: null means "loaded but empty" (clear screen), undefined means "still loading"
|
|
232
|
+
if (data === undefined && this.lastRenderedData !== null && index >= 0) {
|
|
233
|
+
// Check if this index has a pending render (truly async loading)
|
|
234
|
+
// If not pending, it means the render returned no data immediately
|
|
235
|
+
if (this.isPendingRender(index)) {
|
|
236
|
+
// Don't clear - keep showing the last frame while loading
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Clear canvas
|
|
241
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
242
|
+
// If no subtitle at this index, we're done
|
|
243
|
+
if (index < 0 || !data || data.compositionData.length === 0) {
|
|
244
|
+
this.lastRenderedData = null;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
// Store for potential reuse
|
|
248
|
+
this.lastRenderedData = data;
|
|
249
|
+
// Calculate base scale factors
|
|
250
|
+
const baseScaleX = this.canvas.width / data.width;
|
|
251
|
+
const baseScaleY = this.canvas.height / data.height;
|
|
252
|
+
// Apply display settings
|
|
253
|
+
const { scale, verticalOffset } = this.displaySettings;
|
|
254
|
+
const scaleX = baseScaleX * scale;
|
|
255
|
+
const scaleY = baseScaleY * scale;
|
|
256
|
+
const offsetY = (verticalOffset / 100) * this.canvas.height;
|
|
257
|
+
for (const comp of data.compositionData) {
|
|
258
|
+
if (!this.tempCanvas || !this.tempCtx)
|
|
259
|
+
continue;
|
|
260
|
+
// Resize temp canvas if needed
|
|
261
|
+
if (this.tempCanvas.width !== comp.pixelData.width || this.tempCanvas.height !== comp.pixelData.height) {
|
|
262
|
+
this.tempCanvas.width = comp.pixelData.width;
|
|
263
|
+
this.tempCanvas.height = comp.pixelData.height;
|
|
264
|
+
}
|
|
265
|
+
this.tempCtx.putImageData(comp.pixelData, 0, 0);
|
|
266
|
+
// Calculate position with scale and offset applied
|
|
267
|
+
// Center the scaled content horizontally
|
|
268
|
+
const scaledWidth = comp.pixelData.width * scaleX;
|
|
269
|
+
const scaledHeight = comp.pixelData.height * scaleY;
|
|
270
|
+
const baseX = comp.x * baseScaleX;
|
|
271
|
+
const baseY = comp.y * baseScaleY;
|
|
272
|
+
const centeredX = baseX + (comp.pixelData.width * baseScaleX - scaledWidth) / 2;
|
|
273
|
+
const adjustedY = baseY + offsetY + (comp.pixelData.height * baseScaleY - scaledHeight);
|
|
274
|
+
this.ctx.drawImage(this.tempCanvas, centeredX, adjustedY, scaledWidth, scaledHeight);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** Dispose of all resources. */
|
|
278
|
+
dispose() {
|
|
279
|
+
this.disposed = true;
|
|
280
|
+
if (this.animationFrameId !== null) {
|
|
281
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
282
|
+
this.animationFrameId = null;
|
|
283
|
+
}
|
|
284
|
+
this.resizeObserver?.disconnect();
|
|
285
|
+
this.resizeObserver = null;
|
|
286
|
+
this.canvas?.parentElement?.removeChild(this.canvas);
|
|
287
|
+
this.canvas = null;
|
|
288
|
+
this.ctx = null;
|
|
289
|
+
this.tempCanvas = null;
|
|
290
|
+
this.tempCtx = null;
|
|
291
|
+
this.lastRenderedData = null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* High-level PGS subtitle renderer with Web Worker support.
|
|
296
|
+
* Compatible with the old libpgs-js API.
|
|
297
|
+
*/
|
|
298
|
+
export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
299
|
+
pgsParser = null;
|
|
300
|
+
state = createWorkerState();
|
|
301
|
+
onLoading;
|
|
302
|
+
onLoaded;
|
|
303
|
+
onError;
|
|
304
|
+
constructor(options) {
|
|
305
|
+
super(options);
|
|
306
|
+
this.onLoading = options.onLoading;
|
|
307
|
+
this.onLoaded = options.onLoaded;
|
|
308
|
+
this.onError = options.onError;
|
|
309
|
+
this.startInit();
|
|
310
|
+
}
|
|
311
|
+
async loadSubtitles() {
|
|
312
|
+
try {
|
|
313
|
+
this.onLoading?.();
|
|
314
|
+
const response = await fetch(this.subUrl);
|
|
315
|
+
if (!response.ok)
|
|
316
|
+
throw new Error(`Failed to fetch subtitle: ${response.status}`);
|
|
317
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
318
|
+
const data = new Uint8Array(arrayBuffer);
|
|
319
|
+
if (this.state.useWorker) {
|
|
320
|
+
try {
|
|
321
|
+
await getOrCreateWorker();
|
|
322
|
+
const loadResponse = await sendToWorker({ type: 'loadPgs', data: data.buffer.slice(0) });
|
|
323
|
+
if (loadResponse.type === 'pgsLoaded') {
|
|
324
|
+
this.state.workerReady = true;
|
|
325
|
+
const tsResponse = await sendToWorker({ type: 'getPgsTimestamps' });
|
|
326
|
+
if (tsResponse.type === 'pgsTimestamps') {
|
|
327
|
+
this.state.timestamps = tsResponse.timestamps;
|
|
328
|
+
}
|
|
329
|
+
this.isLoaded = true;
|
|
330
|
+
console.log(`[libbitsub] PGS loaded (worker): ${loadResponse.count} display sets from ${loadResponse.byteLength} bytes`);
|
|
331
|
+
this.onLoaded?.();
|
|
332
|
+
return; // Success, don't fall through to main thread
|
|
333
|
+
}
|
|
334
|
+
else if (loadResponse.type === 'error') {
|
|
335
|
+
throw new Error(loadResponse.message);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (workerError) {
|
|
339
|
+
console.warn('[libbitsub] Worker failed, falling back to main thread:', workerError);
|
|
340
|
+
this.state.useWorker = false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Main thread fallback - use idle callback to avoid blocking UI
|
|
344
|
+
await this.loadOnMainThread(data);
|
|
345
|
+
this.onLoaded?.();
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
console.error('Failed to load PGS subtitles:', error);
|
|
349
|
+
this.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async loadOnMainThread(data) {
|
|
353
|
+
// Yield to browser before heavy parsing
|
|
354
|
+
await this.yieldToMain();
|
|
355
|
+
this.pgsParser = new PgsParser();
|
|
356
|
+
// Parse in a microtask to allow UI to update
|
|
357
|
+
await new Promise((resolve) => {
|
|
358
|
+
// Use requestIdleCallback if available, otherwise setTimeout
|
|
359
|
+
const scheduleTask = typeof requestIdleCallback !== 'undefined'
|
|
360
|
+
? (cb) => requestIdleCallback(() => cb(), { timeout: 1000 })
|
|
361
|
+
: (cb) => setTimeout(cb, 0);
|
|
362
|
+
scheduleTask(() => {
|
|
363
|
+
const count = this.pgsParser.load(data);
|
|
364
|
+
this.state.timestamps = this.pgsParser.getTimestamps();
|
|
365
|
+
this.isLoaded = true;
|
|
366
|
+
console.log(`[libbitsub] PGS loaded (main thread): ${count} display sets from ${data.byteLength} bytes`);
|
|
367
|
+
resolve();
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
/** Yield to main thread to prevent UI blocking */
|
|
372
|
+
yieldToMain() {
|
|
373
|
+
// Use scheduler.yield if available (Chrome 115+)
|
|
374
|
+
const globalScheduler = globalThis.scheduler;
|
|
375
|
+
if (globalScheduler && typeof globalScheduler.yield === 'function') {
|
|
376
|
+
return globalScheduler.yield();
|
|
377
|
+
}
|
|
378
|
+
// Fallback to setTimeout
|
|
379
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
380
|
+
}
|
|
381
|
+
renderAtTime(time) {
|
|
382
|
+
const index = this.findCurrentIndex(time);
|
|
383
|
+
return index < 0 ? undefined : this.renderAtIndex(index);
|
|
384
|
+
}
|
|
385
|
+
findCurrentIndex(time) {
|
|
386
|
+
if (this.state.useWorker && this.state.workerReady) {
|
|
387
|
+
return binarySearchTimestamp(this.state.timestamps, time * 1000);
|
|
388
|
+
}
|
|
389
|
+
return this.pgsParser?.findIndexAtTimestamp(time) ?? -1;
|
|
390
|
+
}
|
|
391
|
+
renderAtIndex(index) {
|
|
392
|
+
if (this.state.useWorker && this.state.workerReady) {
|
|
393
|
+
if (this.state.frameCache.has(index)) {
|
|
394
|
+
return this.state.frameCache.get(index) ?? undefined;
|
|
395
|
+
}
|
|
396
|
+
if (!this.state.pendingRenders.has(index)) {
|
|
397
|
+
const renderPromise = sendToWorker({ type: 'renderPgsAtIndex', index }).then((response) => response.type === 'pgsFrame' && response.frame ? convertFrameData(response.frame) : null);
|
|
398
|
+
this.state.pendingRenders.set(index, renderPromise);
|
|
399
|
+
renderPromise.then((result) => {
|
|
400
|
+
this.state.frameCache.set(index, result);
|
|
401
|
+
this.state.pendingRenders.delete(index);
|
|
402
|
+
// Force re-render on next frame by resetting lastRenderedIndex
|
|
403
|
+
if (this.findCurrentIndex(this.video.currentTime) === index) {
|
|
404
|
+
this.lastRenderedIndex = -1;
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
// Return undefined to indicate async loading in progress
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
return this.pgsParser?.renderAtIndex(index);
|
|
412
|
+
}
|
|
413
|
+
isPendingRender(index) {
|
|
414
|
+
return this.state.pendingRenders.has(index);
|
|
415
|
+
}
|
|
416
|
+
onSeek() {
|
|
417
|
+
this.state.frameCache.clear();
|
|
418
|
+
this.state.pendingRenders.clear();
|
|
419
|
+
if (this.state.useWorker && this.state.workerReady) {
|
|
420
|
+
sendToWorker({ type: 'clearPgsCache' }).catch(() => { });
|
|
421
|
+
}
|
|
422
|
+
this.pgsParser?.clearCache();
|
|
423
|
+
}
|
|
424
|
+
/** Get performance statistics for PGS renderer */
|
|
425
|
+
getStats() {
|
|
426
|
+
const baseStats = this.getBaseStats();
|
|
427
|
+
return {
|
|
428
|
+
...baseStats,
|
|
429
|
+
usingWorker: this.state.useWorker && this.state.workerReady,
|
|
430
|
+
cachedFrames: this.state.frameCache.size,
|
|
431
|
+
pendingRenders: this.state.pendingRenders.size,
|
|
432
|
+
totalEntries: this.state.timestamps.length || (this.pgsParser?.getTimestamps().length ?? 0)
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
dispose() {
|
|
436
|
+
super.dispose();
|
|
437
|
+
this.state.frameCache.clear();
|
|
438
|
+
this.state.pendingRenders.clear();
|
|
439
|
+
if (this.state.useWorker && this.state.workerReady) {
|
|
440
|
+
sendToWorker({ type: 'disposePgs' }).catch(() => { });
|
|
441
|
+
}
|
|
442
|
+
this.pgsParser?.dispose();
|
|
443
|
+
this.pgsParser = null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* High-level VobSub subtitle renderer with Web Worker support.
|
|
448
|
+
* Compatible with the old libpgs-js API.
|
|
449
|
+
*/
|
|
450
|
+
export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
451
|
+
vobsubParser = null;
|
|
452
|
+
idxUrl;
|
|
453
|
+
state = createWorkerState();
|
|
454
|
+
onLoading;
|
|
455
|
+
onLoaded;
|
|
456
|
+
onError;
|
|
457
|
+
// Async index lookup state
|
|
458
|
+
cachedIndex = -1;
|
|
459
|
+
cachedIndexTime = -1;
|
|
460
|
+
pendingIndexLookup = null;
|
|
461
|
+
constructor(options) {
|
|
462
|
+
super(options);
|
|
463
|
+
this.idxUrl = options.idxUrl || options.subUrl.replace(/\\.sub$/i, '.idx');
|
|
464
|
+
this.onLoading = options.onLoading;
|
|
465
|
+
this.onLoaded = options.onLoaded;
|
|
466
|
+
this.onError = options.onError;
|
|
467
|
+
this.startInit();
|
|
468
|
+
}
|
|
469
|
+
async loadSubtitles() {
|
|
470
|
+
try {
|
|
471
|
+
this.onLoading?.();
|
|
472
|
+
console.log(`[libbitsub] Loading VobSub: ${this.subUrl}, ${this.idxUrl}`);
|
|
473
|
+
const [subResponse, idxResponse] = await Promise.all([fetch(this.subUrl), fetch(this.idxUrl)]);
|
|
474
|
+
if (!subResponse.ok)
|
|
475
|
+
throw new Error(`Failed to fetch .sub file: ${subResponse.status}`);
|
|
476
|
+
if (!idxResponse.ok)
|
|
477
|
+
throw new Error(`Failed to fetch .idx file: ${idxResponse.status}`);
|
|
478
|
+
const subArrayBuffer = await subResponse.arrayBuffer();
|
|
479
|
+
const idxData = await idxResponse.text();
|
|
480
|
+
const subData = new Uint8Array(subArrayBuffer);
|
|
481
|
+
console.log(`[libbitsub] VobSub files loaded: .sub=${subArrayBuffer.byteLength} bytes, .idx=${idxData.length} chars`);
|
|
482
|
+
if (this.state.useWorker) {
|
|
483
|
+
try {
|
|
484
|
+
await getOrCreateWorker();
|
|
485
|
+
const loadResponse = await sendToWorker({
|
|
486
|
+
type: 'loadVobSub',
|
|
487
|
+
idxContent: idxData,
|
|
488
|
+
subData: subData.buffer.slice(0)
|
|
489
|
+
});
|
|
490
|
+
if (loadResponse.type === 'vobSubLoaded') {
|
|
491
|
+
this.state.workerReady = true;
|
|
492
|
+
const tsResponse = await sendToWorker({ type: 'getVobSubTimestamps' });
|
|
493
|
+
if (tsResponse.type === 'vobSubTimestamps') {
|
|
494
|
+
this.state.timestamps = tsResponse.timestamps;
|
|
495
|
+
}
|
|
496
|
+
this.isLoaded = true;
|
|
497
|
+
console.log(`[libbitsub] VobSub loaded (worker): ${loadResponse.count} subtitle entries`);
|
|
498
|
+
this.onLoaded?.();
|
|
499
|
+
return; // Success, don't fall through to main thread
|
|
500
|
+
}
|
|
501
|
+
else if (loadResponse.type === 'error') {
|
|
502
|
+
throw new Error(loadResponse.message);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch (workerError) {
|
|
506
|
+
console.warn('[libbitsub] Worker failed, falling back to main thread:', workerError);
|
|
507
|
+
this.state.useWorker = false;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// Main thread fallback
|
|
511
|
+
await this.loadOnMainThread(idxData, subData);
|
|
512
|
+
this.onLoaded?.();
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
console.error('Failed to load VobSub subtitles:', error);
|
|
516
|
+
this.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async loadOnMainThread(idxData, subData) {
|
|
520
|
+
// Yield to browser before heavy parsing
|
|
521
|
+
await this.yieldToMain();
|
|
522
|
+
this.vobsubParser = new VobSubParserLowLevel();
|
|
523
|
+
// Parse in a microtask to allow UI to update
|
|
524
|
+
await new Promise((resolve) => {
|
|
525
|
+
const scheduleTask = typeof requestIdleCallback !== 'undefined'
|
|
526
|
+
? (cb) => requestIdleCallback(() => cb(), { timeout: 1000 })
|
|
527
|
+
: (cb) => setTimeout(cb, 0);
|
|
528
|
+
scheduleTask(() => {
|
|
529
|
+
this.vobsubParser.loadFromData(idxData, subData);
|
|
530
|
+
this.state.timestamps = this.vobsubParser.getTimestamps();
|
|
531
|
+
console.log(`[libbitsub] VobSub loaded (main thread): ${this.vobsubParser.count} subtitle entries`);
|
|
532
|
+
this.isLoaded = true;
|
|
533
|
+
resolve();
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
/** Yield to main thread to prevent UI blocking */
|
|
538
|
+
yieldToMain() {
|
|
539
|
+
const globalScheduler = globalThis.scheduler;
|
|
540
|
+
if (globalScheduler && typeof globalScheduler.yield === 'function') {
|
|
541
|
+
return globalScheduler.yield();
|
|
542
|
+
}
|
|
543
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
544
|
+
}
|
|
545
|
+
renderAtTime(time) {
|
|
546
|
+
const index = this.findCurrentIndex(time);
|
|
547
|
+
return index < 0 ? undefined : this.renderAtIndex(index);
|
|
548
|
+
}
|
|
549
|
+
findCurrentIndex(time) {
|
|
550
|
+
if (this.state.useWorker && this.state.workerReady) {
|
|
551
|
+
const timeMs = time * 1000;
|
|
552
|
+
// Only use cache if time is very close (within 1 frame)
|
|
553
|
+
const timeDelta = timeMs - this.cachedIndexTime;
|
|
554
|
+
const cacheValid = this.cachedIndexTime >= 0 && Math.abs(timeDelta) < 17;
|
|
555
|
+
if (cacheValid) {
|
|
556
|
+
return this.cachedIndex;
|
|
557
|
+
}
|
|
558
|
+
// Start async lookup if not already pending
|
|
559
|
+
if (!this.pendingIndexLookup) {
|
|
560
|
+
this.pendingIndexLookup = sendToWorker({ type: 'findVobSubIndex', timeMs }).then((response) => {
|
|
561
|
+
if (response.type === 'vobSubIndex') {
|
|
562
|
+
const newIndex = response.index;
|
|
563
|
+
const oldIndex = this.cachedIndex;
|
|
564
|
+
this.cachedIndex = newIndex;
|
|
565
|
+
this.cachedIndexTime = timeMs;
|
|
566
|
+
// Force re-render if index changed (including to -1 for clear)
|
|
567
|
+
if (oldIndex !== newIndex) {
|
|
568
|
+
this.lastRenderedIndex = -2; // Use -2 to force update even when new index is -1
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
this.pendingIndexLookup = null;
|
|
572
|
+
return this.cachedIndex;
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
return this.cachedIndex;
|
|
576
|
+
}
|
|
577
|
+
return this.vobsubParser?.findIndexAtTimestamp(time) ?? -1;
|
|
578
|
+
}
|
|
579
|
+
renderAtIndex(index) {
|
|
580
|
+
if (this.state.useWorker && this.state.workerReady) {
|
|
581
|
+
// Return cached frame immediately if available
|
|
582
|
+
if (this.state.frameCache.has(index)) {
|
|
583
|
+
return this.state.frameCache.get(index) ?? undefined;
|
|
584
|
+
}
|
|
585
|
+
// Start async render if not already pending
|
|
586
|
+
if (!this.state.pendingRenders.has(index)) {
|
|
587
|
+
const renderPromise = sendToWorker({ type: 'renderVobSubAtIndex', index }).then((response) => response.type === 'vobSubFrame' && response.frame ? convertFrameData(response.frame) : null);
|
|
588
|
+
this.state.pendingRenders.set(index, renderPromise);
|
|
589
|
+
renderPromise.then((result) => {
|
|
590
|
+
this.state.frameCache.set(index, result);
|
|
591
|
+
this.state.pendingRenders.delete(index);
|
|
592
|
+
// Force re-render on next frame by resetting lastRenderedIndex
|
|
593
|
+
if (this.findCurrentIndex(this.video.currentTime) === index) {
|
|
594
|
+
this.lastRenderedIndex = -1;
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
// Return undefined to indicate async loading in progress
|
|
599
|
+
return undefined;
|
|
600
|
+
}
|
|
601
|
+
return this.vobsubParser?.renderAtIndex(index);
|
|
602
|
+
}
|
|
603
|
+
isPendingRender(index) {
|
|
604
|
+
return this.state.pendingRenders.has(index);
|
|
605
|
+
}
|
|
606
|
+
onSeek() {
|
|
607
|
+
this.state.frameCache.clear();
|
|
608
|
+
this.state.pendingRenders.clear();
|
|
609
|
+
// Clear cached index lookup on seek
|
|
610
|
+
this.cachedIndex = -1;
|
|
611
|
+
this.cachedIndexTime = -1;
|
|
612
|
+
this.pendingIndexLookup = null;
|
|
613
|
+
if (this.state.useWorker && this.state.workerReady) {
|
|
614
|
+
sendToWorker({ type: 'clearVobSubCache' }).catch(() => { });
|
|
615
|
+
}
|
|
616
|
+
this.vobsubParser?.clearCache();
|
|
617
|
+
}
|
|
618
|
+
/** Get performance statistics for VobSub renderer */
|
|
619
|
+
getStats() {
|
|
620
|
+
const baseStats = this.getBaseStats();
|
|
621
|
+
return {
|
|
622
|
+
...baseStats,
|
|
623
|
+
usingWorker: this.state.useWorker && this.state.workerReady,
|
|
624
|
+
cachedFrames: this.state.frameCache.size,
|
|
625
|
+
pendingRenders: this.state.pendingRenders.size,
|
|
626
|
+
totalEntries: this.state.timestamps.length || (this.vobsubParser?.getTimestamps().length ?? 0)
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
dispose() {
|
|
630
|
+
super.dispose();
|
|
631
|
+
this.state.frameCache.clear();
|
|
632
|
+
this.state.pendingRenders.clear();
|
|
633
|
+
if (this.state.useWorker && this.state.workerReady) {
|
|
634
|
+
sendToWorker({ type: 'disposeVobSub' }).catch(() => { });
|
|
635
|
+
}
|
|
636
|
+
this.vobsubParser?.dispose();
|
|
637
|
+
this.vobsubParser = null;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
//# sourceMappingURL=renderers.js.map
|