@mediafox/core 1.2.8 → 1.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/compositor/compositor.d.ts.map +1 -1
- package/dist/compositor-worker.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +4 -3
- package/src/compositor/audio-manager.ts +411 -0
- package/src/compositor/compositor-worker.ts +158 -0
- package/src/compositor/compositor.ts +931 -0
- package/src/compositor/index.ts +19 -0
- package/src/compositor/source-pool.ts +450 -0
- package/src/compositor/types.ts +103 -0
- package/src/compositor/worker-client.ts +139 -0
- package/src/compositor/worker-types.ts +67 -0
- package/src/core/player-core.ts +273 -0
- package/src/core/state-facade.ts +98 -0
- package/src/core/track-switcher.ts +127 -0
- package/src/events/emitter.ts +137 -0
- package/src/events/types.ts +24 -0
- package/src/index.ts +124 -0
- package/src/mediafox.ts +642 -0
- package/src/playback/audio.ts +361 -0
- package/src/playback/controller.ts +446 -0
- package/src/playback/renderer.ts +1176 -0
- package/src/playback/renderers/canvas2d.ts +128 -0
- package/src/playback/renderers/factory.ts +172 -0
- package/src/playback/renderers/index.ts +5 -0
- package/src/playback/renderers/types.ts +57 -0
- package/src/playback/renderers/webgl.ts +373 -0
- package/src/playback/renderers/webgpu.ts +395 -0
- package/src/playlist/manager.ts +268 -0
- package/src/plugins/context.ts +93 -0
- package/src/plugins/index.ts +15 -0
- package/src/plugins/manager.ts +482 -0
- package/src/plugins/types.ts +243 -0
- package/src/sources/manager.ts +285 -0
- package/src/sources/source.ts +84 -0
- package/src/sources/types.ts +17 -0
- package/src/state/store.ts +389 -0
- package/src/state/types.ts +18 -0
- package/src/tracks/manager.ts +421 -0
- package/src/tracks/types.ts +30 -0
- package/src/types/jassub.d.ts +1 -0
- package/src/types.ts +235 -0
- package/src/utils/async-lock.ts +26 -0
- package/src/utils/dispose.ts +28 -0
- package/src/utils/equal.ts +33 -0
- package/src/utils/errors.ts +74 -0
- package/src/utils/logger.ts +50 -0
- package/src/utils/time.ts +157 -0
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
import { EventEmitter } from '../events/emitter';
|
|
2
|
+
import type { MediaSource } from '../types';
|
|
3
|
+
import { CompositorAudioManager } from './audio-manager';
|
|
4
|
+
import { SourcePool } from './source-pool';
|
|
5
|
+
import type {
|
|
6
|
+
AudioLayer,
|
|
7
|
+
CompositionFrame,
|
|
8
|
+
CompositorEventListener,
|
|
9
|
+
CompositorEventMap,
|
|
10
|
+
CompositorLayer,
|
|
11
|
+
CompositorOptions,
|
|
12
|
+
CompositorSource,
|
|
13
|
+
CompositorSourceOptions,
|
|
14
|
+
FrameExportOptions,
|
|
15
|
+
PreviewOptions,
|
|
16
|
+
} from './types';
|
|
17
|
+
import { CompositorWorkerClient } from './worker-client';
|
|
18
|
+
import type { CompositorWorkerFrame, CompositorWorkerSourceInfo } from './worker-types';
|
|
19
|
+
|
|
20
|
+
interface CompositorState {
|
|
21
|
+
playing: boolean;
|
|
22
|
+
currentTime: number;
|
|
23
|
+
duration: number;
|
|
24
|
+
seeking: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Pre-allocated arrays for render loop to reduce GC pressure
|
|
28
|
+
interface RenderBuffers {
|
|
29
|
+
visibleLayers: CompositorLayer[];
|
|
30
|
+
framePromises: Promise<CanvasImageSource | null>[];
|
|
31
|
+
frameImages: (CanvasImageSource | null)[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Canvas-based video compositor for composing multiple media sources into a single output.
|
|
36
|
+
* Supports layered rendering with transforms, opacity, and rotation.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* const compositor = new Compositor({
|
|
41
|
+
* canvas: document.querySelector('canvas'),
|
|
42
|
+
* width: 1920,
|
|
43
|
+
* height: 1080
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* const source = await compositor.loadSource('video.mp4');
|
|
47
|
+
* compositor.preview({
|
|
48
|
+
* duration: 10,
|
|
49
|
+
* getComposition: (time) => ({
|
|
50
|
+
* time,
|
|
51
|
+
* layers: [{ source, transform: { opacity: 1 } }]
|
|
52
|
+
* })
|
|
53
|
+
* });
|
|
54
|
+
* compositor.play();
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export class Compositor {
|
|
58
|
+
private canvas: HTMLCanvasElement | OffscreenCanvas;
|
|
59
|
+
private ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null;
|
|
60
|
+
private width: number;
|
|
61
|
+
private height: number;
|
|
62
|
+
private backgroundColor: string;
|
|
63
|
+
private sourcePool: SourcePool;
|
|
64
|
+
private audioManager: CompositorAudioManager | null = null;
|
|
65
|
+
private workerClient: CompositorWorkerClient | null = null;
|
|
66
|
+
private workerSources = new Map<string, CompositorSource>();
|
|
67
|
+
private workerAudioSources = new Map<string, CompositorSource>();
|
|
68
|
+
private emitter: EventEmitter<CompositorEventMap>;
|
|
69
|
+
private state: CompositorState;
|
|
70
|
+
private animationFrameId: number | null = null;
|
|
71
|
+
private lastFrameTime = 0;
|
|
72
|
+
private lastRenderTime = 0;
|
|
73
|
+
private previewOptions: PreviewOptions | null = null;
|
|
74
|
+
private disposed = false;
|
|
75
|
+
|
|
76
|
+
// Performance optimizations
|
|
77
|
+
private renderBuffers: RenderBuffers = { visibleLayers: [], framePromises: [], frameImages: [] };
|
|
78
|
+
private lastTimeUpdateEmit = 0;
|
|
79
|
+
private timeUpdateThrottleMs = 100; // ~10Hz
|
|
80
|
+
private renderPending = false;
|
|
81
|
+
|
|
82
|
+
// Audio state
|
|
83
|
+
private activeAudioSourceIds = new Set<string>();
|
|
84
|
+
private audioScratch = {
|
|
85
|
+
nextActiveSourceIds: new Set<string>(),
|
|
86
|
+
newSourceIds: [] as string[],
|
|
87
|
+
newSourceTimes: [] as number[],
|
|
88
|
+
};
|
|
89
|
+
private registeredAudioSources = new Set<string>();
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Creates a new Compositor instance.
|
|
93
|
+
* @param options - Configuration options for the compositor
|
|
94
|
+
*/
|
|
95
|
+
constructor(options: CompositorOptions) {
|
|
96
|
+
this.canvas = options.canvas;
|
|
97
|
+
this.width = options.width ?? (this.canvas.width || 1920);
|
|
98
|
+
this.height = options.height ?? (this.canvas.height || 1080);
|
|
99
|
+
this.backgroundColor = options.backgroundColor ?? '#000000';
|
|
100
|
+
this.emitter = new EventEmitter({ maxListeners: 50 });
|
|
101
|
+
this.state = {
|
|
102
|
+
playing: false,
|
|
103
|
+
currentTime: 0,
|
|
104
|
+
duration: 0,
|
|
105
|
+
seeking: false,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Set canvas dimensions
|
|
109
|
+
this.canvas.width = this.width;
|
|
110
|
+
this.canvas.height = this.height;
|
|
111
|
+
|
|
112
|
+
const workerEnabled =
|
|
113
|
+
typeof options.worker === 'boolean'
|
|
114
|
+
? options.worker
|
|
115
|
+
: options.worker
|
|
116
|
+
? (options.worker.enabled ?? true)
|
|
117
|
+
: false;
|
|
118
|
+
const canUseWorker =
|
|
119
|
+
workerEnabled &&
|
|
120
|
+
typeof Worker !== 'undefined' &&
|
|
121
|
+
typeof OffscreenCanvas !== 'undefined' &&
|
|
122
|
+
typeof (this.canvas as HTMLCanvasElement).transferControlToOffscreen === 'function' &&
|
|
123
|
+
!(this.canvas instanceof OffscreenCanvas);
|
|
124
|
+
|
|
125
|
+
if (workerEnabled && !canUseWorker) {
|
|
126
|
+
throw new Error('Worker compositor requires HTMLCanvasElement, OffscreenCanvas, and Worker support');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.sourcePool = new SourcePool();
|
|
130
|
+
|
|
131
|
+
if (canUseWorker) {
|
|
132
|
+
try {
|
|
133
|
+
this.workerClient = new CompositorWorkerClient({
|
|
134
|
+
canvas: this.canvas as HTMLCanvasElement,
|
|
135
|
+
width: this.width,
|
|
136
|
+
height: this.height,
|
|
137
|
+
backgroundColor: this.backgroundColor,
|
|
138
|
+
worker: options.worker ?? true,
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.warn('[Compositor] Worker initialization failed, falling back to main thread rendering:', err);
|
|
142
|
+
this.workerClient = null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (options.enableAudio !== false) {
|
|
147
|
+
this.audioManager = new CompositorAudioManager();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!this.workerClient) {
|
|
151
|
+
// Get 2D context
|
|
152
|
+
this.ctx = this.canvas.getContext('2d', {
|
|
153
|
+
alpha: false,
|
|
154
|
+
desynchronized: true,
|
|
155
|
+
}) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
|
|
156
|
+
|
|
157
|
+
if (!this.ctx) {
|
|
158
|
+
throw new Error('Failed to get 2D context for compositor canvas');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Initial clear
|
|
162
|
+
this.clear();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Source Management
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Loads a video source into the compositor's source pool.
|
|
170
|
+
* @param source - Video source (URL, File, Blob, or MediaStream)
|
|
171
|
+
* @param options - Optional loading configuration
|
|
172
|
+
* @returns The loaded compositor source
|
|
173
|
+
*/
|
|
174
|
+
async loadSource(source: MediaSource, options?: CompositorSourceOptions): Promise<CompositorSource> {
|
|
175
|
+
this.checkDisposed();
|
|
176
|
+
if (this.workerClient) {
|
|
177
|
+
const info = await this.workerClient.loadSource(source, options);
|
|
178
|
+
const proxy = this.createWorkerSource(info);
|
|
179
|
+
if (this.audioManager && info.hasAudio) {
|
|
180
|
+
await this.loadWorkerAudio(source, proxy.id);
|
|
181
|
+
}
|
|
182
|
+
this.emitter.emit('sourceloaded', { id: proxy.id, source: proxy });
|
|
183
|
+
return proxy;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const loaded = await this.sourcePool.loadVideo(source, options);
|
|
187
|
+
|
|
188
|
+
// Register audio with audio manager if available
|
|
189
|
+
this.registerSourceAudio(loaded);
|
|
190
|
+
|
|
191
|
+
this.emitter.emit('sourceloaded', { id: loaded.id, source: loaded });
|
|
192
|
+
return loaded;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Loads an image source into the compositor's source pool.
|
|
197
|
+
* @param source - Image source (URL, File, or Blob)
|
|
198
|
+
* @returns The loaded compositor source
|
|
199
|
+
*/
|
|
200
|
+
async loadImage(source: string | Blob | File): Promise<CompositorSource> {
|
|
201
|
+
this.checkDisposed();
|
|
202
|
+
if (this.workerClient) {
|
|
203
|
+
const info = await this.workerClient.loadImage(source);
|
|
204
|
+
const proxy = this.createWorkerSource(info);
|
|
205
|
+
this.emitter.emit('sourceloaded', { id: proxy.id, source: proxy });
|
|
206
|
+
return proxy;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const loaded = await this.sourcePool.loadImage(source);
|
|
210
|
+
this.emitter.emit('sourceloaded', { id: loaded.id, source: loaded });
|
|
211
|
+
return loaded;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Loads an audio source into the compositor's source pool.
|
|
216
|
+
* @param source - Audio source (URL, File, Blob, or MediaStream)
|
|
217
|
+
* @param options - Optional loading configuration
|
|
218
|
+
* @returns The loaded compositor source
|
|
219
|
+
*/
|
|
220
|
+
async loadAudio(source: MediaSource, options?: CompositorSourceOptions): Promise<CompositorSource> {
|
|
221
|
+
this.checkDisposed();
|
|
222
|
+
if (this.workerClient) {
|
|
223
|
+
const info = await this.workerClient.loadAudio(source, options);
|
|
224
|
+
const proxy = this.createWorkerSource(info);
|
|
225
|
+
if (this.audioManager) {
|
|
226
|
+
await this.loadWorkerAudio(source, proxy.id);
|
|
227
|
+
}
|
|
228
|
+
this.emitter.emit('sourceloaded', { id: proxy.id, source: proxy });
|
|
229
|
+
return proxy;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const loaded = await this.sourcePool.loadAudio(source, options);
|
|
233
|
+
|
|
234
|
+
// Register audio with audio manager
|
|
235
|
+
this.registerSourceAudio(loaded);
|
|
236
|
+
|
|
237
|
+
this.emitter.emit('sourceloaded', { id: loaded.id, source: loaded });
|
|
238
|
+
return loaded;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Unloads a source from the compositor's source pool.
|
|
243
|
+
* @param id - The source ID to unload
|
|
244
|
+
* @returns True if the source was found and unloaded
|
|
245
|
+
*/
|
|
246
|
+
unloadSource(id: string): boolean {
|
|
247
|
+
if (this.workerClient) {
|
|
248
|
+
const source = this.workerSources.get(id);
|
|
249
|
+
if (!source) return false;
|
|
250
|
+
void this.workerClient.unloadSource(id);
|
|
251
|
+
this.workerSources.delete(id);
|
|
252
|
+
this.unloadWorkerAudio(id);
|
|
253
|
+
this.emitter.emit('sourceunloaded', { id });
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Unregister audio before unloading
|
|
258
|
+
if (this.registeredAudioSources.has(id)) {
|
|
259
|
+
this.audioManager?.unregisterSource(id);
|
|
260
|
+
this.registeredAudioSources.delete(id);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const result = this.sourcePool.unloadSource(id);
|
|
264
|
+
if (result) {
|
|
265
|
+
this.emitter.emit('sourceunloaded', { id });
|
|
266
|
+
}
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Registers a source's audio with the audio manager.
|
|
272
|
+
*/
|
|
273
|
+
private registerSourceAudio(source: CompositorSource): void {
|
|
274
|
+
if (!this.audioManager) return;
|
|
275
|
+
if (this.registeredAudioSources.has(source.id)) return;
|
|
276
|
+
|
|
277
|
+
const audioBufferSink = source.getAudioBufferSink?.();
|
|
278
|
+
if (audioBufferSink) {
|
|
279
|
+
this.audioManager.registerSource(source, audioBufferSink);
|
|
280
|
+
this.registeredAudioSources.add(source.id);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Processes audio layers for the current frame.
|
|
286
|
+
*/
|
|
287
|
+
private processAudioLayers(layers: AudioLayer[], mediaTime: number): void {
|
|
288
|
+
if (!this.audioManager) return;
|
|
289
|
+
const newSourceIds = this.audioScratch.newSourceIds;
|
|
290
|
+
const newSourceTimes = this.audioScratch.newSourceTimes;
|
|
291
|
+
const nextActiveSourceIds = this.audioScratch.nextActiveSourceIds;
|
|
292
|
+
const previousActiveSourceIds = this.activeAudioSourceIds;
|
|
293
|
+
|
|
294
|
+
newSourceIds.length = 0;
|
|
295
|
+
newSourceTimes.length = 0;
|
|
296
|
+
nextActiveSourceIds.clear();
|
|
297
|
+
|
|
298
|
+
for (let i = 0; i < layers.length; i++) {
|
|
299
|
+
const layer = layers[i];
|
|
300
|
+
if (layer.muted) continue;
|
|
301
|
+
|
|
302
|
+
const sourceId = layer.source.id;
|
|
303
|
+
if (!this.audioManager.hasSource(sourceId)) continue;
|
|
304
|
+
|
|
305
|
+
nextActiveSourceIds.add(sourceId);
|
|
306
|
+
|
|
307
|
+
if (!previousActiveSourceIds.has(sourceId)) {
|
|
308
|
+
newSourceIds.push(sourceId);
|
|
309
|
+
newSourceTimes.push(layer.sourceTime ?? mediaTime);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Update active sources
|
|
314
|
+
if (previousActiveSourceIds.size > 0) {
|
|
315
|
+
previousActiveSourceIds.clear();
|
|
316
|
+
}
|
|
317
|
+
for (const sourceId of nextActiveSourceIds) {
|
|
318
|
+
previousActiveSourceIds.add(sourceId);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Process layers with audio manager
|
|
322
|
+
this.audioManager.processAudioLayers(layers, mediaTime);
|
|
323
|
+
|
|
324
|
+
// Start playback for new sources
|
|
325
|
+
for (let i = 0; i < newSourceIds.length; i++) {
|
|
326
|
+
this.audioManager.startSourcePlayback(newSourceIds[i], newSourceTimes[i]);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Gets a source by ID from the source pool.
|
|
332
|
+
* @param id - The source ID
|
|
333
|
+
* @returns The source if found, undefined otherwise
|
|
334
|
+
*/
|
|
335
|
+
getSource(id: string): CompositorSource | undefined {
|
|
336
|
+
if (this.workerClient) {
|
|
337
|
+
return this.workerSources.get(id);
|
|
338
|
+
}
|
|
339
|
+
return this.sourcePool.getSource(id);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Gets all sources currently loaded in the source pool.
|
|
344
|
+
* @returns Array of all loaded sources
|
|
345
|
+
*/
|
|
346
|
+
getAllSources(): CompositorSource[] {
|
|
347
|
+
if (this.workerClient) {
|
|
348
|
+
return Array.from(this.workerSources.values());
|
|
349
|
+
}
|
|
350
|
+
return this.sourcePool.getAllSources();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Rendering
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Renders a composition frame to the canvas.
|
|
357
|
+
* Fetches all layer frames in parallel before drawing to prevent flicker.
|
|
358
|
+
* @param frame - The composition frame to render
|
|
359
|
+
* @returns True if rendering succeeded
|
|
360
|
+
*/
|
|
361
|
+
async render(frame: CompositionFrame): Promise<boolean> {
|
|
362
|
+
this.checkDisposed();
|
|
363
|
+
if (this.workerClient) {
|
|
364
|
+
const workerFrame = this.serializeWorkerFrame(frame);
|
|
365
|
+
return this.workerClient.render(workerFrame);
|
|
366
|
+
}
|
|
367
|
+
const ctx = this.ctx;
|
|
368
|
+
if (!ctx) return false;
|
|
369
|
+
|
|
370
|
+
// Reuse pre-allocated arrays
|
|
371
|
+
const { visibleLayers, framePromises, frameImages } = this.renderBuffers;
|
|
372
|
+
visibleLayers.length = 0;
|
|
373
|
+
framePromises.length = 0;
|
|
374
|
+
frameImages.length = 0;
|
|
375
|
+
|
|
376
|
+
// Filter visible layers into pre-allocated array, track sort order
|
|
377
|
+
let needsSort = false;
|
|
378
|
+
let lastZIndex = -Infinity;
|
|
379
|
+
const layers = frame.layers;
|
|
380
|
+
for (let i = 0; i < layers.length; i++) {
|
|
381
|
+
const layer = layers[i];
|
|
382
|
+
if (layer.visible === false) continue;
|
|
383
|
+
|
|
384
|
+
const zIndex = layer.zIndex ?? 0;
|
|
385
|
+
if (zIndex < lastZIndex) {
|
|
386
|
+
needsSort = true;
|
|
387
|
+
}
|
|
388
|
+
lastZIndex = zIndex;
|
|
389
|
+
|
|
390
|
+
visibleLayers.push(layer);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (visibleLayers.length === 0) {
|
|
394
|
+
ctx.fillStyle = this.backgroundColor;
|
|
395
|
+
ctx.fillRect(0, 0, this.width, this.height);
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (needsSort) {
|
|
400
|
+
visibleLayers.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Fetch all frames in parallel (promises already in flight), store results densely
|
|
404
|
+
for (let i = 0; i < visibleLayers.length; i++) {
|
|
405
|
+
const layer = visibleLayers[i];
|
|
406
|
+
const sourceTime = layer.sourceTime ?? frame.time;
|
|
407
|
+
framePromises[i] = layer.source.getFrameAt(sourceTime);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const images = await Promise.all(framePromises);
|
|
411
|
+
for (let i = 0; i < images.length; i++) {
|
|
412
|
+
frameImages[i] = images[i] ?? null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Clear and render - synchronous to prevent flicker
|
|
416
|
+
ctx.fillStyle = this.backgroundColor;
|
|
417
|
+
ctx.fillRect(0, 0, this.width, this.height);
|
|
418
|
+
|
|
419
|
+
// Render in order, skip null entries (failed fetches)
|
|
420
|
+
for (let i = 0; i < visibleLayers.length; i++) {
|
|
421
|
+
const image = frameImages[i];
|
|
422
|
+
if (image) {
|
|
423
|
+
this.renderLayer(image, visibleLayers[i]);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private renderLayer(image: CanvasImageSource, layer: CompositorLayer): void {
|
|
431
|
+
const ctx = this.ctx;
|
|
432
|
+
if (!ctx) return;
|
|
433
|
+
|
|
434
|
+
const transform = layer.transform;
|
|
435
|
+
const sourceWidth = layer.source.width ?? this.width;
|
|
436
|
+
const sourceHeight = layer.source.height ?? this.height;
|
|
437
|
+
|
|
438
|
+
// Fast path: no transform object means draw at origin with source dimensions
|
|
439
|
+
if (!transform) {
|
|
440
|
+
ctx.drawImage(image, 0, 0, sourceWidth, sourceHeight);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const destWidth = transform.width ?? sourceWidth;
|
|
445
|
+
const destHeight = transform.height ?? sourceHeight;
|
|
446
|
+
const x = transform.x ?? 0;
|
|
447
|
+
const y = transform.y ?? 0;
|
|
448
|
+
const rotation = transform.rotation ?? 0;
|
|
449
|
+
const scaleX = transform.scaleX ?? 1;
|
|
450
|
+
const scaleY = transform.scaleY ?? 1;
|
|
451
|
+
const opacity = transform.opacity ?? 1;
|
|
452
|
+
|
|
453
|
+
// Check if we need context state changes
|
|
454
|
+
const needsOpacity = opacity !== 1;
|
|
455
|
+
const needsTransform = rotation !== 0 || scaleX !== 1 || scaleY !== 1;
|
|
456
|
+
|
|
457
|
+
// Fast path: simple position/size only, no rotation/scale/opacity
|
|
458
|
+
if (!needsOpacity && !needsTransform) {
|
|
459
|
+
ctx.drawImage(image, x, y, destWidth, destHeight);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const anchorX = transform.anchorX ?? 0.5;
|
|
464
|
+
const anchorY = transform.anchorY ?? 0.5;
|
|
465
|
+
|
|
466
|
+
// Save context state only when needed
|
|
467
|
+
ctx.save();
|
|
468
|
+
|
|
469
|
+
if (needsOpacity) {
|
|
470
|
+
ctx.globalAlpha = opacity;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Move to layer position
|
|
474
|
+
ctx.translate(x + destWidth * anchorX, y + destHeight * anchorY);
|
|
475
|
+
|
|
476
|
+
if (rotation !== 0) {
|
|
477
|
+
ctx.rotate((rotation * Math.PI) / 180);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (scaleX !== 1 || scaleY !== 1) {
|
|
481
|
+
ctx.scale(scaleX, scaleY);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Draw image centered on anchor point
|
|
485
|
+
ctx.drawImage(image, -destWidth * anchorX, -destHeight * anchorY, destWidth, destHeight);
|
|
486
|
+
|
|
487
|
+
ctx.restore();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private createWorkerSource(info: CompositorWorkerSourceInfo): CompositorSource {
|
|
491
|
+
const proxy: CompositorSource = {
|
|
492
|
+
id: info.id,
|
|
493
|
+
type: info.type,
|
|
494
|
+
duration: info.duration,
|
|
495
|
+
width: info.width,
|
|
496
|
+
height: info.height,
|
|
497
|
+
async getFrameAt(): Promise<CanvasImageSource | null> {
|
|
498
|
+
throw new Error('getFrameAt is not available when worker rendering is enabled');
|
|
499
|
+
},
|
|
500
|
+
getAudioBufferSink(): import('mediabunny').AudioBufferSink | null {
|
|
501
|
+
return null;
|
|
502
|
+
},
|
|
503
|
+
hasAudio(): boolean {
|
|
504
|
+
return info.hasAudio ?? false;
|
|
505
|
+
},
|
|
506
|
+
dispose(): void {
|
|
507
|
+
// Managed by the compositor worker
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
this.workerSources.set(proxy.id, proxy);
|
|
512
|
+
return proxy;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private async loadWorkerAudio(source: MediaSource, id: string): Promise<void> {
|
|
516
|
+
if (!this.audioManager) return;
|
|
517
|
+
|
|
518
|
+
if (this.workerAudioSources.has(id)) {
|
|
519
|
+
this.unloadWorkerAudio(id);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
const audioSource = await this.sourcePool.loadAudio(source, { id });
|
|
524
|
+
this.workerAudioSources.set(id, audioSource);
|
|
525
|
+
this.registerSourceAudio(audioSource);
|
|
526
|
+
} catch {
|
|
527
|
+
// Ignore audio load failures in worker mode
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private unloadWorkerAudio(id: string): void {
|
|
532
|
+
if (!this.audioManager) return;
|
|
533
|
+
|
|
534
|
+
if (this.workerAudioSources.has(id)) {
|
|
535
|
+
this.audioManager.unregisterSource(id);
|
|
536
|
+
this.registeredAudioSources.delete(id);
|
|
537
|
+
this.sourcePool.unloadSource(id);
|
|
538
|
+
this.workerAudioSources.delete(id);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private serializeWorkerFrame(frame: CompositionFrame): CompositorWorkerFrame {
|
|
543
|
+
if (!this.workerClient) {
|
|
544
|
+
throw new Error('Worker compositor not initialized');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const layers = frame.layers;
|
|
548
|
+
const serializedLayers = new Array(layers.length);
|
|
549
|
+
for (let i = 0; i < layers.length; i++) {
|
|
550
|
+
const layer = layers[i];
|
|
551
|
+
const sourceId = layer.source.id;
|
|
552
|
+
if (!this.workerSources.has(sourceId)) {
|
|
553
|
+
throw new Error(`Layer source ${sourceId} is not managed by this compositor`);
|
|
554
|
+
}
|
|
555
|
+
serializedLayers[i] = {
|
|
556
|
+
sourceId,
|
|
557
|
+
sourceTime: layer.sourceTime,
|
|
558
|
+
transform: layer.transform,
|
|
559
|
+
visible: layer.visible,
|
|
560
|
+
zIndex: layer.zIndex,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
time: frame.time,
|
|
566
|
+
layers: serializedLayers,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Clears the canvas with the background color.
|
|
572
|
+
*/
|
|
573
|
+
clear(): void {
|
|
574
|
+
if (this.workerClient) {
|
|
575
|
+
void this.workerClient.clear();
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (!this.ctx) return;
|
|
579
|
+
this.ctx.fillStyle = this.backgroundColor;
|
|
580
|
+
this.ctx.fillRect(0, 0, this.width, this.height);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Preview Playback
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Configures the preview playback with a composition callback.
|
|
587
|
+
* Must be called before play() or seek().
|
|
588
|
+
* @param options - Preview configuration including duration and composition callback
|
|
589
|
+
*/
|
|
590
|
+
preview(options: PreviewOptions): void {
|
|
591
|
+
this.checkDisposed();
|
|
592
|
+
this.previewOptions = options;
|
|
593
|
+
this.state.duration = options.duration;
|
|
594
|
+
this.lastRenderTime = 0;
|
|
595
|
+
this.emitter.emit('compositionchange', undefined);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Starts playback of the preview composition.
|
|
600
|
+
* @throws Error if preview() has not been called first
|
|
601
|
+
*/
|
|
602
|
+
async play(): Promise<void> {
|
|
603
|
+
this.checkDisposed();
|
|
604
|
+
if (this.state.playing) return;
|
|
605
|
+
if (!this.previewOptions) {
|
|
606
|
+
throw new Error('No preview configured. Call preview() first.');
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
this.state.playing = true;
|
|
610
|
+
this.lastFrameTime = performance.now();
|
|
611
|
+
this.lastRenderTime = this.lastFrameTime;
|
|
612
|
+
this.emitter.emit('play', undefined);
|
|
613
|
+
|
|
614
|
+
// Start audio playback
|
|
615
|
+
if (this.audioManager) {
|
|
616
|
+
// Reset active audio tracking so sources restart after pause/seek.
|
|
617
|
+
this.activeAudioSourceIds.clear();
|
|
618
|
+
await this.audioManager.play(this.state.currentTime);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Start render loop
|
|
622
|
+
this.startRenderLoop();
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Pauses playback of the preview composition.
|
|
627
|
+
*/
|
|
628
|
+
pause(): void {
|
|
629
|
+
this.checkDisposed();
|
|
630
|
+
if (!this.state.playing) return;
|
|
631
|
+
|
|
632
|
+
this.state.playing = false;
|
|
633
|
+
this.stopRenderLoop();
|
|
634
|
+
if (this.audioManager) {
|
|
635
|
+
this.audioManager.pause();
|
|
636
|
+
}
|
|
637
|
+
this.emitter.emit('pause', undefined);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Seeks to a specific time in the preview composition.
|
|
642
|
+
* @param time - Time in seconds to seek to
|
|
643
|
+
*/
|
|
644
|
+
async seek(time: number): Promise<void> {
|
|
645
|
+
this.checkDisposed();
|
|
646
|
+
if (!this.previewOptions) return;
|
|
647
|
+
|
|
648
|
+
const clampedTime = Math.max(0, Math.min(time, this.state.duration));
|
|
649
|
+
this.state.seeking = true;
|
|
650
|
+
this.emitter.emit('seeking', { time: clampedTime });
|
|
651
|
+
|
|
652
|
+
this.state.currentTime = clampedTime;
|
|
653
|
+
|
|
654
|
+
// Seek audio
|
|
655
|
+
if (this.audioManager) {
|
|
656
|
+
await this.audioManager.seek(clampedTime);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Render frame at new time
|
|
660
|
+
const frame = this.previewOptions.getComposition(clampedTime);
|
|
661
|
+
await this.render(frame);
|
|
662
|
+
|
|
663
|
+
this.state.seeking = false;
|
|
664
|
+
this.emitter.emit('seeked', { time: clampedTime });
|
|
665
|
+
this.emitter.emit('timeupdate', { currentTime: clampedTime });
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private startRenderLoop(): void {
|
|
669
|
+
if (this.animationFrameId !== null) return;
|
|
670
|
+
|
|
671
|
+
const tick = () => {
|
|
672
|
+
if (!this.state.playing || !this.previewOptions) return;
|
|
673
|
+
|
|
674
|
+
// Schedule next frame IMMEDIATELY to maintain consistent timing
|
|
675
|
+
this.animationFrameId = requestAnimationFrame(tick);
|
|
676
|
+
|
|
677
|
+
const now = performance.now();
|
|
678
|
+
const deltaTime = (now - this.lastFrameTime) / 1000;
|
|
679
|
+
this.lastFrameTime = now;
|
|
680
|
+
|
|
681
|
+
// Update current time
|
|
682
|
+
this.state.currentTime += deltaTime;
|
|
683
|
+
|
|
684
|
+
// Check for end
|
|
685
|
+
if (this.state.currentTime >= this.state.duration) {
|
|
686
|
+
if (this.previewOptions.loop) {
|
|
687
|
+
this.state.currentTime = 0;
|
|
688
|
+
} else {
|
|
689
|
+
this.state.currentTime = this.state.duration;
|
|
690
|
+
this.pause();
|
|
691
|
+
this.emitter.emit('ended', undefined);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const fps = this.previewOptions.fps ?? 0;
|
|
697
|
+
const frameIntervalMs = fps > 0 ? 1000 / fps : 0;
|
|
698
|
+
const shouldRender =
|
|
699
|
+
!this.renderPending && (frameIntervalMs === 0 || now - this.lastRenderTime >= frameIntervalMs);
|
|
700
|
+
|
|
701
|
+
if (shouldRender) {
|
|
702
|
+
// Get composition and render (non-blocking)
|
|
703
|
+
this.renderPending = true;
|
|
704
|
+
this.lastRenderTime = now;
|
|
705
|
+
|
|
706
|
+
const frame = this.previewOptions.getComposition(this.state.currentTime);
|
|
707
|
+
|
|
708
|
+
// Process audio layers
|
|
709
|
+
if (this.audioManager) {
|
|
710
|
+
this.processAudioLayers(frame.audio ?? [], this.state.currentTime);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
this.render(frame)
|
|
714
|
+
.catch(() => {
|
|
715
|
+
// Ignore render errors, will retry next frame
|
|
716
|
+
})
|
|
717
|
+
.finally(() => {
|
|
718
|
+
this.renderPending = false;
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Throttle timeupdate events to ~10Hz
|
|
723
|
+
if (now - this.lastTimeUpdateEmit >= this.timeUpdateThrottleMs) {
|
|
724
|
+
this.lastTimeUpdateEmit = now;
|
|
725
|
+
this.emitter.emit('timeupdate', { currentTime: this.state.currentTime });
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
this.animationFrameId = requestAnimationFrame(tick);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
private stopRenderLoop(): void {
|
|
733
|
+
if (this.animationFrameId !== null) {
|
|
734
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
735
|
+
this.animationFrameId = null;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Frame Export
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Exports a single frame at the specified time as an image blob.
|
|
743
|
+
* @param time - Time in seconds to export
|
|
744
|
+
* @param options - Export options (format, quality)
|
|
745
|
+
* @returns Image blob or null if export failed
|
|
746
|
+
*/
|
|
747
|
+
async exportFrame(time: number, options: FrameExportOptions = {}): Promise<Blob | null> {
|
|
748
|
+
this.checkDisposed();
|
|
749
|
+
if (!this.previewOptions) return null;
|
|
750
|
+
|
|
751
|
+
// Render frame at specified time
|
|
752
|
+
const frame = this.previewOptions.getComposition(time);
|
|
753
|
+
|
|
754
|
+
if (this.workerClient) {
|
|
755
|
+
const workerFrame = this.serializeWorkerFrame(frame);
|
|
756
|
+
return this.workerClient.exportFrame(workerFrame, options);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
await this.render(frame);
|
|
760
|
+
|
|
761
|
+
// Export canvas to blob
|
|
762
|
+
if ('toBlob' in this.canvas) {
|
|
763
|
+
return new Promise((resolve) => {
|
|
764
|
+
(this.canvas as HTMLCanvasElement).toBlob(
|
|
765
|
+
(blob) => resolve(blob),
|
|
766
|
+
`image/${options.format ?? 'png'}`,
|
|
767
|
+
options.quality
|
|
768
|
+
);
|
|
769
|
+
});
|
|
770
|
+
} else {
|
|
771
|
+
return (this.canvas as OffscreenCanvas).convertToBlob({
|
|
772
|
+
type: `image/${options.format ?? 'png'}`,
|
|
773
|
+
quality: options.quality,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// State Getters
|
|
779
|
+
|
|
780
|
+
/** Current playback time in seconds. */
|
|
781
|
+
get currentTime(): number {
|
|
782
|
+
return this.state.currentTime;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Total duration of the preview composition in seconds. */
|
|
786
|
+
get duration(): number {
|
|
787
|
+
return this.state.duration;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Whether the compositor is currently playing. */
|
|
791
|
+
get playing(): boolean {
|
|
792
|
+
return this.state.playing;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Whether the compositor is currently paused. */
|
|
796
|
+
get paused(): boolean {
|
|
797
|
+
return !this.state.playing;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Whether the compositor is currently seeking. */
|
|
801
|
+
get seeking(): boolean {
|
|
802
|
+
return this.state.seeking;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Gets the current canvas width.
|
|
807
|
+
* @returns Width in pixels
|
|
808
|
+
*/
|
|
809
|
+
getWidth(): number {
|
|
810
|
+
return this.width;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Gets the current canvas height.
|
|
815
|
+
* @returns Height in pixels
|
|
816
|
+
*/
|
|
817
|
+
getHeight(): number {
|
|
818
|
+
return this.height;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Resizes the compositor canvas without disposing loaded sources.
|
|
823
|
+
* @param width - New width in pixels
|
|
824
|
+
* @param height - New height in pixels
|
|
825
|
+
*/
|
|
826
|
+
resize(width: number, height: number): void {
|
|
827
|
+
this.checkDisposed();
|
|
828
|
+
this.width = width;
|
|
829
|
+
this.height = height;
|
|
830
|
+
this.canvas.width = width;
|
|
831
|
+
this.canvas.height = height;
|
|
832
|
+
if (this.workerClient) {
|
|
833
|
+
void this.workerClient.resize(width, height);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
this.clear();
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Events
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Subscribes to a compositor event.
|
|
843
|
+
* @param event - Event name to listen for
|
|
844
|
+
* @param listener - Callback function
|
|
845
|
+
* @returns Unsubscribe function
|
|
846
|
+
*/
|
|
847
|
+
on<K extends keyof CompositorEventMap>(event: K, listener: CompositorEventListener<K>): () => void {
|
|
848
|
+
return this.emitter.on(event, listener);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Subscribes to a compositor event for a single invocation.
|
|
853
|
+
* @param event - Event name to listen for
|
|
854
|
+
* @param listener - Callback function
|
|
855
|
+
* @returns Unsubscribe function
|
|
856
|
+
*/
|
|
857
|
+
once<K extends keyof CompositorEventMap>(event: K, listener: CompositorEventListener<K>): () => void {
|
|
858
|
+
return this.emitter.once(event, listener);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Unsubscribes from a compositor event.
|
|
863
|
+
* @param event - Event name to unsubscribe from
|
|
864
|
+
* @param listener - Optional specific listener to remove
|
|
865
|
+
*/
|
|
866
|
+
off<K extends keyof CompositorEventMap>(event: K, listener?: CompositorEventListener<K>): void {
|
|
867
|
+
this.emitter.off(event, listener);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Audio Control
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Sets the master volume for all audio layers.
|
|
874
|
+
* @param volume - Volume level (0 to 1)
|
|
875
|
+
*/
|
|
876
|
+
setVolume(volume: number): void {
|
|
877
|
+
this.audioManager?.setMasterVolume(volume);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Sets the master mute state for all audio layers.
|
|
882
|
+
* @param muted - Whether audio is muted
|
|
883
|
+
*/
|
|
884
|
+
setMuted(muted: boolean): void {
|
|
885
|
+
this.audioManager?.setMasterMuted(muted);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Gets the audio context used by the compositor.
|
|
890
|
+
* Useful for advanced audio processing.
|
|
891
|
+
*/
|
|
892
|
+
getAudioContext(): AudioContext {
|
|
893
|
+
if (!this.audioManager) {
|
|
894
|
+
throw new Error('Audio is disabled for this compositor');
|
|
895
|
+
}
|
|
896
|
+
return this.audioManager.getAudioContext();
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Lifecycle
|
|
900
|
+
|
|
901
|
+
private checkDisposed(): void {
|
|
902
|
+
if (this.disposed) {
|
|
903
|
+
throw new Error('Compositor has been disposed');
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Disposes the compositor and releases all resources.
|
|
909
|
+
* After disposal, the compositor cannot be used.
|
|
910
|
+
*/
|
|
911
|
+
dispose(): void {
|
|
912
|
+
if (this.disposed) return;
|
|
913
|
+
this.disposed = true;
|
|
914
|
+
|
|
915
|
+
this.stopRenderLoop();
|
|
916
|
+
this.audioManager?.dispose();
|
|
917
|
+
this.workerClient?.dispose();
|
|
918
|
+
this.workerClient = null;
|
|
919
|
+
this.sourcePool.dispose();
|
|
920
|
+
this.registeredAudioSources.clear();
|
|
921
|
+
this.activeAudioSourceIds.clear();
|
|
922
|
+
this.audioScratch.nextActiveSourceIds.clear();
|
|
923
|
+
this.audioScratch.newSourceIds.length = 0;
|
|
924
|
+
this.audioScratch.newSourceTimes.length = 0;
|
|
925
|
+
this.workerSources.clear();
|
|
926
|
+
this.workerAudioSources.clear();
|
|
927
|
+
this.emitter.removeAllListeners();
|
|
928
|
+
this.ctx = null;
|
|
929
|
+
this.previewOptions = null;
|
|
930
|
+
}
|
|
931
|
+
}
|