@twick/canvas 0.15.0 → 0.15.2

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/index.js CHANGED
@@ -128,7 +128,6 @@ const reorderElementsByZIndex = (canvas) => {
128
128
  if (!canvas) return;
129
129
  const backgroundColor = canvas.backgroundColor;
130
130
  const objects = canvas.getObjects();
131
- console.log("objects", objects);
132
131
  objects.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
133
132
  canvas.clear();
134
133
  canvas.backgroundColor = backgroundColor;
@@ -213,15 +212,133 @@ const rotateControl = new fabric.Control({
213
212
  /** Whether to show connection line */
214
213
  withConnection: true
215
214
  });
216
- const getThumbnail = async (videoUrl, seekTime = 0.1, playbackRate = 1) => {
217
- return new Promise((resolve, reject) => {
215
+ class LRUCache {
216
+ constructor(maxSize = 100) {
217
+ if (maxSize <= 0) {
218
+ throw new Error("maxSize must be greater than 0");
219
+ }
220
+ this.maxSize = maxSize;
221
+ this.cache = /* @__PURE__ */ new Map();
222
+ }
223
+ /**
224
+ * Get a value from the cache.
225
+ * Moves the item to the end (most recently used).
226
+ */
227
+ get(key) {
228
+ const value = this.cache.get(key);
229
+ if (value === void 0) {
230
+ return void 0;
231
+ }
232
+ this.cache.delete(key);
233
+ this.cache.set(key, value);
234
+ return value;
235
+ }
236
+ /**
237
+ * Set a value in the cache.
238
+ * If cache is full, removes the least recently used item.
239
+ */
240
+ set(key, value) {
241
+ if (this.cache.has(key)) {
242
+ this.cache.delete(key);
243
+ } else if (this.cache.size >= this.maxSize) {
244
+ const firstKey = this.cache.keys().next().value;
245
+ if (firstKey !== void 0) {
246
+ this.cache.delete(firstKey);
247
+ }
248
+ }
249
+ this.cache.set(key, value);
250
+ }
251
+ /**
252
+ * Check if a key exists in the cache.
253
+ */
254
+ has(key) {
255
+ return this.cache.has(key);
256
+ }
257
+ /**
258
+ * Delete a key from the cache.
259
+ */
260
+ delete(key) {
261
+ return this.cache.delete(key);
262
+ }
263
+ /**
264
+ * Clear all entries from the cache.
265
+ */
266
+ clear() {
267
+ this.cache.clear();
268
+ }
269
+ /**
270
+ * Get the current size of the cache.
271
+ */
272
+ get size() {
273
+ return this.cache.size;
274
+ }
275
+ }
276
+ class VideoFrameExtractor {
277
+ constructor(options = {}) {
278
+ this.frameCache = new LRUCache(
279
+ options.maxCacheSize ?? 50
280
+ );
281
+ this.videoElements = /* @__PURE__ */ new Map();
282
+ this.maxVideoElements = options.maxVideoElements ?? 5;
283
+ this.loadTimeout = options.loadTimeout ?? 15e3;
284
+ this.jpegQuality = options.jpegQuality ?? 0.8;
285
+ this.playbackRate = options.playbackRate ?? 1;
286
+ }
287
+ /**
288
+ * Get a frame thumbnail from a video at a specific time.
289
+ * Uses caching and reuses video elements for optimal performance.
290
+ *
291
+ * @param videoUrl - The URL of the video
292
+ * @param seekTime - The time in seconds to extract the frame
293
+ * @returns Promise resolving to a thumbnail image URL (data URL or blob URL)
294
+ */
295
+ async getFrame(videoUrl, seekTime = 0.1) {
296
+ const cacheKey = this.getCacheKey(videoUrl, seekTime);
297
+ const cached = this.frameCache.get(cacheKey);
298
+ if (cached) {
299
+ return cached;
300
+ }
301
+ const videoState = await this.getVideoElement(videoUrl);
302
+ const thumbnail = await this.extractFrame(videoState.video, seekTime);
303
+ this.frameCache.set(cacheKey, thumbnail);
304
+ return thumbnail;
305
+ }
306
+ /**
307
+ * Get or create a video element for the given URL.
308
+ * Reuses existing elements and manages cleanup.
309
+ */
310
+ async getVideoElement(videoUrl) {
311
+ let videoState = this.videoElements.get(videoUrl);
312
+ if (videoState && videoState.isReady) {
313
+ videoState.lastUsed = Date.now();
314
+ return videoState;
315
+ }
316
+ if (videoState && videoState.isLoading && videoState.loadPromise) {
317
+ await videoState.loadPromise;
318
+ if (videoState.isReady) {
319
+ videoState.lastUsed = Date.now();
320
+ return videoState;
321
+ }
322
+ }
323
+ if (this.videoElements.size >= this.maxVideoElements) {
324
+ this.cleanupOldVideoElements();
325
+ }
326
+ videoState = await this.createVideoElement(videoUrl);
327
+ this.videoElements.set(videoUrl, videoState);
328
+ videoState.lastUsed = Date.now();
329
+ return videoState;
330
+ }
331
+ /**
332
+ * Create and initialize a new video element.
333
+ */
334
+ async createVideoElement(videoUrl) {
218
335
  const video = document.createElement("video");
219
336
  video.crossOrigin = "anonymous";
220
337
  video.muted = true;
221
338
  video.playsInline = true;
222
339
  video.autoplay = false;
223
340
  video.preload = "auto";
224
- video.playbackRate = playbackRate;
341
+ video.playbackRate = this.playbackRate;
225
342
  video.style.position = "absolute";
226
343
  video.style.left = "-9999px";
227
344
  video.style.top = "-9999px";
@@ -230,55 +347,128 @@ const getThumbnail = async (videoUrl, seekTime = 0.1, playbackRate = 1) => {
230
347
  video.style.opacity = "0";
231
348
  video.style.pointerEvents = "none";
232
349
  video.style.zIndex = "-1";
233
- let timeoutId;
234
- const cleanup = () => {
235
- if (video.parentNode) video.remove();
236
- if (timeoutId) clearTimeout(timeoutId);
237
- };
238
- const handleError = () => {
239
- var _a;
240
- cleanup();
241
- reject(new Error(`Failed to load video: ${((_a = video.error) == null ? void 0 : _a.message) || "Unknown error"}`));
350
+ const state = {
351
+ video,
352
+ isReady: false,
353
+ isLoading: true,
354
+ loadPromise: null,
355
+ lastUsed: Date.now()
242
356
  };
243
- const handleSeeked = () => {
244
- try {
245
- video.pause();
246
- const canvas = document.createElement("canvas");
247
- const width = video.videoWidth || 640;
248
- const height = video.videoHeight || 360;
249
- canvas.width = width;
250
- canvas.height = height;
251
- const ctx = canvas.getContext("2d");
252
- if (!ctx) {
253
- cleanup();
254
- reject(new Error("Failed to get canvas context"));
357
+ state.loadPromise = new Promise((resolve, reject) => {
358
+ let timeoutId;
359
+ const cleanup = () => {
360
+ if (timeoutId) clearTimeout(timeoutId);
361
+ };
362
+ const handleError = () => {
363
+ var _a;
364
+ cleanup();
365
+ state.isLoading = false;
366
+ reject(new Error(`Failed to load video: ${((_a = video.error) == null ? void 0 : _a.message) || "Unknown error"}`));
367
+ };
368
+ const handleLoadedMetadata = () => {
369
+ cleanup();
370
+ state.isReady = true;
371
+ state.isLoading = false;
372
+ resolve();
373
+ };
374
+ video.addEventListener("error", handleError, { once: true });
375
+ video.addEventListener("loadedmetadata", handleLoadedMetadata, { once: true });
376
+ timeoutId = window.setTimeout(() => {
377
+ cleanup();
378
+ state.isLoading = false;
379
+ reject(new Error("Video loading timed out"));
380
+ }, this.loadTimeout);
381
+ video.src = videoUrl;
382
+ document.body.appendChild(video);
383
+ });
384
+ try {
385
+ await state.loadPromise;
386
+ } catch (error) {
387
+ if (video.parentNode) {
388
+ video.remove();
389
+ }
390
+ throw error;
391
+ }
392
+ return state;
393
+ }
394
+ /**
395
+ * Extract a frame from a video at the specified time.
396
+ */
397
+ async extractFrame(video, seekTime) {
398
+ return new Promise((resolve, reject) => {
399
+ video.pause();
400
+ const timeThreshold = 0.1;
401
+ if (Math.abs(video.currentTime - seekTime) < timeThreshold) {
402
+ try {
403
+ const canvas = document.createElement("canvas");
404
+ const width = video.videoWidth || 640;
405
+ const height = video.videoHeight || 360;
406
+ canvas.width = width;
407
+ canvas.height = height;
408
+ const ctx = canvas.getContext("2d");
409
+ if (!ctx) {
410
+ reject(new Error("Failed to get canvas context"));
411
+ return;
412
+ }
413
+ ctx.drawImage(video, 0, 0, width, height);
414
+ try {
415
+ const dataUrl = canvas.toDataURL("image/jpeg", this.jpegQuality);
416
+ resolve(dataUrl);
417
+ } catch {
418
+ canvas.toBlob(
419
+ (blob) => {
420
+ if (!blob) {
421
+ reject(new Error("Failed to create Blob"));
422
+ return;
423
+ }
424
+ const blobUrl = URL.createObjectURL(blob);
425
+ resolve(blobUrl);
426
+ },
427
+ "image/jpeg",
428
+ this.jpegQuality
429
+ );
430
+ }
431
+ return;
432
+ } catch (err) {
433
+ reject(new Error(`Error creating thumbnail: ${err}`));
255
434
  return;
256
435
  }
257
- ctx.drawImage(video, 0, 0, width, height);
436
+ }
437
+ const handleSeeked = () => {
258
438
  try {
259
- const dataUrl = canvas.toDataURL("image/jpeg", 0.8);
260
- cleanup();
261
- resolve(dataUrl);
262
- } catch {
263
- canvas.toBlob((blob) => {
264
- if (!blob) {
265
- cleanup();
266
- reject(new Error("Failed to create Blob"));
267
- return;
268
- }
269
- const blobUrl = URL.createObjectURL(blob);
270
- cleanup();
271
- resolve(blobUrl);
272
- }, "image/jpeg", 0.8);
439
+ const canvas = document.createElement("canvas");
440
+ const width = video.videoWidth || 640;
441
+ const height = video.videoHeight || 360;
442
+ canvas.width = width;
443
+ canvas.height = height;
444
+ const ctx = canvas.getContext("2d");
445
+ if (!ctx) {
446
+ reject(new Error("Failed to get canvas context"));
447
+ return;
448
+ }
449
+ ctx.drawImage(video, 0, 0, width, height);
450
+ try {
451
+ const dataUrl = canvas.toDataURL("image/jpeg", this.jpegQuality);
452
+ resolve(dataUrl);
453
+ } catch {
454
+ canvas.toBlob(
455
+ (blob) => {
456
+ if (!blob) {
457
+ reject(new Error("Failed to create Blob"));
458
+ return;
459
+ }
460
+ const blobUrl = URL.createObjectURL(blob);
461
+ resolve(blobUrl);
462
+ },
463
+ "image/jpeg",
464
+ this.jpegQuality
465
+ );
466
+ }
467
+ } catch (err) {
468
+ reject(new Error(`Error creating thumbnail: ${err}`));
273
469
  }
274
- } catch (err) {
275
- cleanup();
276
- reject(new Error(`Error creating thumbnail: ${err}`));
277
- }
278
- };
279
- video.addEventListener("error", handleError, { once: true });
280
- video.addEventListener("seeked", handleSeeked, { once: true });
281
- video.addEventListener("loadedmetadata", () => {
470
+ };
471
+ video.addEventListener("seeked", handleSeeked, { once: true });
282
472
  const playPromise = video.play();
283
473
  if (playPromise !== void 0) {
284
474
  playPromise.then(() => {
@@ -289,15 +479,80 @@ const getThumbnail = async (videoUrl, seekTime = 0.1, playbackRate = 1) => {
289
479
  } else {
290
480
  video.currentTime = seekTime;
291
481
  }
292
- }, { once: true });
293
- timeoutId = window.setTimeout(() => {
294
- cleanup();
295
- reject(new Error("Video loading timed out"));
296
- }, 15e3);
297
- video.src = videoUrl;
298
- document.body.appendChild(video);
299
- });
300
- };
482
+ });
483
+ }
484
+ /**
485
+ * Generate cache key for a video URL and seek time.
486
+ */
487
+ getCacheKey(videoUrl, seekTime) {
488
+ const roundedTime = Math.round(seekTime * 100) / 100;
489
+ return `${videoUrl}:${roundedTime}`;
490
+ }
491
+ /**
492
+ * Cleanup least recently used video elements.
493
+ */
494
+ cleanupOldVideoElements() {
495
+ if (this.videoElements.size < this.maxVideoElements) {
496
+ return;
497
+ }
498
+ const entries = Array.from(this.videoElements.entries());
499
+ entries.sort((a, b) => a[1].lastUsed - b[1].lastUsed);
500
+ const toRemove = entries.slice(0, entries.length - this.maxVideoElements + 1);
501
+ for (const [url, state] of toRemove) {
502
+ if (state.video.parentNode) {
503
+ state.video.remove();
504
+ }
505
+ this.videoElements.delete(url);
506
+ }
507
+ }
508
+ /**
509
+ * Clear the frame cache.
510
+ */
511
+ clearCache() {
512
+ this.frameCache.clear();
513
+ }
514
+ /**
515
+ * Remove a specific video element and clear its cached frames.
516
+ */
517
+ removeVideo(videoUrl) {
518
+ const state = this.videoElements.get(videoUrl);
519
+ if (state) {
520
+ if (state.video.parentNode) {
521
+ state.video.remove();
522
+ }
523
+ this.videoElements.delete(videoUrl);
524
+ }
525
+ this.frameCache.clear();
526
+ }
527
+ /**
528
+ * Dispose of all video elements and clear caches.
529
+ * Removes all video elements from the DOM and clears both the frame cache
530
+ * and video element cache. Call this when the extractor is no longer needed
531
+ * to prevent memory leaks.
532
+ */
533
+ dispose() {
534
+ for (const state of this.videoElements.values()) {
535
+ if (state.video.parentNode) {
536
+ state.video.remove();
537
+ }
538
+ }
539
+ this.videoElements.clear();
540
+ this.frameCache.clear();
541
+ }
542
+ }
543
+ let defaultExtractor = null;
544
+ function getDefaultVideoFrameExtractor(options) {
545
+ if (!defaultExtractor) {
546
+ defaultExtractor = new VideoFrameExtractor(options);
547
+ }
548
+ return defaultExtractor;
549
+ }
550
+ async function getThumbnailCached(videoUrl, seekTime = 0.1, playbackRate) {
551
+ const extractor = getDefaultVideoFrameExtractor(
552
+ void 0
553
+ );
554
+ return extractor.getFrame(videoUrl, seekTime);
555
+ }
301
556
  const getObjectFitSize = (objectFit, elementSize, containerSize) => {
302
557
  const elementAspectRatio = elementSize.width / elementSize.height;
303
558
  const containerAspectRatio = containerSize.width / containerSize.height;
@@ -479,7 +734,7 @@ const addVideoElement = async ({
479
734
  }) => {
480
735
  var _a;
481
736
  try {
482
- const thumbnailUrl = await getThumbnail(
737
+ const thumbnailUrl = await getThumbnailCached(
483
738
  ((_a = element == null ? void 0 : element.props) == null ? void 0 : _a.src) || "",
484
739
  snapTime
485
740
  );