@sangwonl/pocato-core 0.4.3 → 0.4.4

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.d.ts CHANGED
@@ -11,6 +11,9 @@ interface LayerSource {
11
11
  src: string;
12
12
  type?: 'image' | 'video';
13
13
  freeze?: number | 'random';
14
+ freezeSeed?: number;
15
+ loop?: boolean;
16
+ playbackRate?: number;
14
17
  }
15
18
  type EffectUniformValue = string | number | boolean | [number, number] | [number, number, number] | [number, number, number, number];
16
19
  interface FaceFrameOptions {
package/dist/index.js CHANGED
@@ -381,12 +381,38 @@ function getTransparentTexture() {
381
381
  return transparentTexture;
382
382
  }
383
383
  var pendingVideos = /* @__PURE__ */ new Set();
384
+ function debugLog(message, data) {
385
+ try {
386
+ if (globalThis.localStorage?.getItem("pocatoDebug") === "1") {
387
+ console.warn(`[pocato] ${message}`, data ?? {});
388
+ }
389
+ } catch {
390
+ }
391
+ }
392
+ function applyVideoPlaybackOptions(video, layer) {
393
+ const playbackRate = layer.playbackRate ?? 1;
394
+ video.defaultPlaybackRate = playbackRate;
395
+ video.playbackRate = playbackRate;
396
+ }
397
+ function getRandomValue(seed) {
398
+ if (seed == null || !Number.isFinite(seed)) return Math.random();
399
+ return Math.abs(seed) % 1;
400
+ }
401
+ function getFreezeTargetTime(freezeTime, duration, seed) {
402
+ if (freezeTime < 0) {
403
+ return getRandomValue(seed) * (Number.isFinite(duration) ? duration : 1);
404
+ }
405
+ return Number.isFinite(duration) ? Math.min(freezeTime, duration) : freezeTime;
406
+ }
407
+ function isAtTargetTime(video, targetTime) {
408
+ return Math.abs(video.currentTime - targetTime) < 0.05;
409
+ }
384
410
  function loadLayerTexture(layer, onError) {
385
411
  if (isVideoSource(layer)) {
386
412
  if (layer.freeze != null) {
387
- return loadFrozenVideoTexture(layer.src, layer.freeze === "random" ? -1 : layer.freeze, onError);
413
+ return loadFrozenVideoTexture(layer, layer.freeze === "random" ? -1 : layer.freeze, onError);
388
414
  }
389
- return loadVideoTexture(layer.src, onError);
415
+ return loadVideoTexture(layer, onError);
390
416
  }
391
417
  return loadImageTexture(layer.src, onError);
392
418
  }
@@ -414,12 +440,13 @@ function loadImageTexture(src, onError) {
414
440
  }
415
441
  return pending.then((texture) => ({ texture }));
416
442
  }
417
- function loadVideoTexture(src, onError) {
443
+ function loadVideoTexture(layer, onError) {
418
444
  return new Promise((resolve) => {
419
445
  const video = document.createElement("video");
420
- video.src = src;
446
+ video.crossOrigin = "anonymous";
421
447
  video.muted = true;
422
- video.loop = true;
448
+ video.loop = layer.loop ?? true;
449
+ applyVideoPlaybackOptions(video, layer);
423
450
  video.playsInline = true;
424
451
  video.preload = "auto";
425
452
  video.style.position = "fixed";
@@ -433,27 +460,34 @@ function loadVideoTexture(src, onError) {
433
460
  video.addEventListener("error", () => {
434
461
  pendingVideos.delete(video);
435
462
  const msg = video.error?.message ?? "Unknown video error";
436
- onError?.(new Error(`Failed to load video texture: ${src} (${msg})`));
463
+ onError?.(new Error(`Failed to load video texture: ${layer.src} (${msg})`));
437
464
  video.remove();
438
465
  resolve({ texture: getTransparentTexture() });
439
466
  }, { once: true });
467
+ video.src = layer.src;
440
468
  video.play().then(() => {
441
469
  pendingVideos.delete(video);
442
470
  const texture = new THREE2.VideoTexture(video);
443
471
  resolve({ texture, videoEl: video });
444
472
  }).catch(() => {
445
473
  pendingVideos.delete(video);
446
- onError?.(new Error(`Video autoplay blocked: ${src}`));
474
+ onError?.(new Error(`Video autoplay blocked: ${layer.src}`));
447
475
  video.remove();
448
476
  resolve({ texture: getTransparentTexture() });
449
477
  });
450
478
  });
451
479
  }
452
- function loadFrozenVideoTexture(src, freezeTime, onError) {
480
+ function loadFrozenVideoTexture(layer, freezeTime, onError) {
453
481
  return new Promise((resolve) => {
482
+ debugLog("freeze load start", {
483
+ src: layer.src,
484
+ freezeTime,
485
+ freezeSeed: layer.freezeSeed
486
+ });
454
487
  const video = document.createElement("video");
455
- video.src = src;
488
+ video.crossOrigin = "anonymous";
456
489
  video.muted = true;
490
+ applyVideoPlaybackOptions(video, layer);
457
491
  video.playsInline = true;
458
492
  video.preload = "auto";
459
493
  video.style.position = "fixed";
@@ -464,31 +498,137 @@ function loadFrozenVideoTexture(src, freezeTime, onError) {
464
498
  video.style.zIndex = "-9999";
465
499
  document.body.appendChild(video);
466
500
  pendingVideos.add(video);
467
- video.addEventListener("error", () => {
468
- pendingVideos.delete(video);
469
- const msg = video.error?.message ?? "Unknown video error";
470
- onError?.(new Error(`Failed to load video texture: ${src} (${msg})`));
471
- video.remove();
472
- resolve({ texture: getTransparentTexture() });
473
- }, { once: true });
474
- video.addEventListener("loadeddata", () => {
475
- const time = freezeTime < 0 ? Math.random() * (video.duration || 1) : Math.min(freezeTime, video.duration || 0);
476
- video.currentTime = time;
477
- }, { once: true });
478
- video.addEventListener("seeked", () => {
501
+ let settled = false;
502
+ let targetTime = 0;
503
+ let retrySeekRegistered = false;
504
+ const captureFrame = () => {
505
+ if (settled) return;
506
+ settled = true;
507
+ video.pause();
508
+ debugLog("freeze capture frame", {
509
+ src: layer.src,
510
+ currentTime: video.currentTime,
511
+ duration: video.duration,
512
+ readyState: video.readyState,
513
+ videoWidth: video.videoWidth,
514
+ videoHeight: video.videoHeight
515
+ });
479
516
  const canvas = document.createElement("canvas");
480
- canvas.width = video.videoWidth;
481
- canvas.height = video.videoHeight;
517
+ canvas.width = video.videoWidth || 1;
518
+ canvas.height = video.videoHeight || 1;
482
519
  const ctx = canvas.getContext("2d");
483
- ctx.drawImage(video, 0, 0);
520
+ if (!ctx) {
521
+ pendingVideos.delete(video);
522
+ video.src = "";
523
+ video.remove();
524
+ resolve({ texture: getTransparentTexture() });
525
+ return;
526
+ }
527
+ try {
528
+ ctx.drawImage(video, 0, 0);
529
+ } catch (error) {
530
+ debugLog("freeze drawImage failed", {
531
+ src: layer.src,
532
+ error: error instanceof Error ? error.message : String(error)
533
+ });
534
+ pendingVideos.delete(video);
535
+ video.src = "";
536
+ video.remove();
537
+ onError?.(error instanceof Error ? error : new Error(String(error)));
538
+ resolve({ texture: getTransparentTexture() });
539
+ return;
540
+ }
484
541
  const texture = new THREE2.CanvasTexture(canvas);
485
542
  texture.needsUpdate = true;
543
+ debugLog("freeze canvas texture ready", {
544
+ src: layer.src,
545
+ width: canvas.width,
546
+ height: canvas.height
547
+ });
486
548
  pendingVideos.delete(video);
487
- video.pause();
488
549
  video.src = "";
489
550
  video.remove();
490
551
  resolve({ texture });
552
+ };
553
+ const captureReadyFrame = () => {
554
+ debugLog("freeze wait decoded frame", {
555
+ src: layer.src,
556
+ currentTime: video.currentTime,
557
+ readyState: video.readyState
558
+ });
559
+ if ("requestVideoFrameCallback" in video) {
560
+ video.requestVideoFrameCallback(() => captureFrame());
561
+ }
562
+ const playPromise = video.play();
563
+ playPromise.catch(() => {
564
+ debugLog("freeze play rejected, fallback capture", { src: layer.src });
565
+ window.setTimeout(captureFrame, 0);
566
+ });
567
+ if (!("requestVideoFrameCallback" in video)) {
568
+ window.setTimeout(captureFrame, 0);
569
+ }
570
+ };
571
+ const retrySeekWhenReady = () => {
572
+ if (settled) return;
573
+ if (retrySeekRegistered) return;
574
+ retrySeekRegistered = true;
575
+ const retry = () => {
576
+ retrySeekRegistered = false;
577
+ if (settled) return;
578
+ debugLog("freeze retry seek", {
579
+ src: layer.src,
580
+ targetTime,
581
+ currentTime: video.currentTime,
582
+ duration: video.duration,
583
+ readyState: video.readyState
584
+ });
585
+ video.currentTime = targetTime;
586
+ };
587
+ video.addEventListener("durationchange", retry, { once: true });
588
+ video.addEventListener("canplay", retry, { once: true });
589
+ video.addEventListener("loadeddata", retry, { once: true });
590
+ };
591
+ video.addEventListener("error", () => {
592
+ pendingVideos.delete(video);
593
+ const msg = video.error?.message ?? "Unknown video error";
594
+ onError?.(new Error(`Failed to load video texture: ${layer.src} (${msg})`));
595
+ video.remove();
596
+ resolve({ texture: getTransparentTexture() });
597
+ }, { once: true });
598
+ video.addEventListener("loadedmetadata", () => {
599
+ const time = getFreezeTargetTime(freezeTime, video.duration, layer.freezeSeed);
600
+ targetTime = time;
601
+ debugLog("freeze metadata loaded", {
602
+ src: layer.src,
603
+ targetTime: time,
604
+ duration: video.duration,
605
+ readyState: video.readyState
606
+ });
607
+ if (isAtTargetTime(video, time)) {
608
+ if (video.readyState >= 2) {
609
+ captureReadyFrame();
610
+ } else {
611
+ video.addEventListener("loadeddata", () => captureReadyFrame(), { once: true });
612
+ }
613
+ return;
614
+ }
615
+ video.currentTime = time;
491
616
  }, { once: true });
617
+ video.addEventListener("seeked", () => {
618
+ debugLog("freeze seeked", {
619
+ src: layer.src,
620
+ targetTime,
621
+ currentTime: video.currentTime,
622
+ duration: video.duration,
623
+ readyState: video.readyState
624
+ });
625
+ if (!isAtTargetTime(video, targetTime)) {
626
+ retrySeekWhenReady();
627
+ return;
628
+ }
629
+ captureReadyFrame();
630
+ });
631
+ video.src = layer.src;
492
632
  video.load();
493
633
  });
494
634
  }
@@ -1823,6 +1963,7 @@ var MAX_LAYERS = 8;
1823
1963
  var FaceRenderer = class {
1824
1964
  constructor(shader, width, height, effectUniforms) {
1825
1965
  this.loadedLayers = [];
1966
+ this.layerLoadVersion = 0;
1826
1967
  bootstrapShaders();
1827
1968
  this.scene = new THREE3.Scene();
1828
1969
  this.camera = new THREE3.Camera();
@@ -1872,6 +2013,7 @@ var FaceRenderer = class {
1872
2013
  }
1873
2014
  }
1874
2015
  async loadLayers(layers, onError) {
2016
+ const version = ++this.layerLoadVersion;
1875
2017
  this.disposeLayers();
1876
2018
  const count = Math.min(layers.length, MAX_LAYERS);
1877
2019
  this.material.uniforms.uLayerCount.value = count;
@@ -1879,6 +2021,12 @@ var FaceRenderer = class {
1879
2021
  (layer) => loadLayerTexture(layer, onError)
1880
2022
  );
1881
2023
  const results = await Promise.all(promises);
2024
+ if (version !== this.layerLoadVersion) {
2025
+ for (const loaded of results) {
2026
+ disposeLoadedLayer(loaded);
2027
+ }
2028
+ return;
2029
+ }
1882
2030
  this.loadedLayers = results;
1883
2031
  for (let i = 0; i < results.length; i++) {
1884
2032
  this.material.uniforms[`uLayer${i}`].value = results[i].texture;
@@ -1914,6 +2062,7 @@ var FaceRenderer = class {
1914
2062
  this.material.uniforms.uLayerCount.value = 0;
1915
2063
  }
1916
2064
  destroy() {
2065
+ this.layerLoadVersion++;
1917
2066
  this.disposeLayers();
1918
2067
  this.mesh.geometry.dispose();
1919
2068
  this.material.dispose();
@@ -2018,10 +2167,10 @@ var Renderer = class {
2018
2167
  this.loadAllLayers();
2019
2168
  this.setupResizeObserver();
2020
2169
  this.setupIntersectionObserver();
2021
- requestAnimationFrame(() => {
2022
- this.cardEl.classList.remove("pocato-loading");
2023
- this.cardEl.classList.add("pocato-ready");
2024
- });
2170
+ }
2171
+ disposeWebGLRenderer(renderer) {
2172
+ renderer.forceContextLoss();
2173
+ renderer.dispose();
2025
2174
  }
2026
2175
  createFrontRenderer() {
2027
2176
  if (this.frontRenderer) return;
@@ -2052,7 +2201,7 @@ var Renderer = class {
2052
2201
  disposeFrontRenderer() {
2053
2202
  if (!this.frontRenderer) return;
2054
2203
  this.frontRenderer.render(this.frontFace.scene, this.frontFace.camera);
2055
- this.frontRenderer.dispose();
2204
+ this.disposeWebGLRenderer(this.frontRenderer);
2056
2205
  this.frontRenderer = null;
2057
2206
  }
2058
2207
  disposeBackRenderer() {
@@ -2060,7 +2209,7 @@ var Renderer = class {
2060
2209
  if (this.backFace) {
2061
2210
  this.backRenderer.render(this.backFace.scene, this.backFace.camera);
2062
2211
  }
2063
- this.backRenderer.dispose();
2212
+ this.disposeWebGLRenderer(this.backRenderer);
2064
2213
  this.backRenderer = null;
2065
2214
  }
2066
2215
  activate() {
@@ -2103,7 +2252,13 @@ var Renderer = class {
2103
2252
  promises.push(this.backFace.loadLayers(this.options.back.layers, this.onError));
2104
2253
  }
2105
2254
  await Promise.all(promises);
2106
- this.onReady?.();
2255
+ if (this.destroyed) return;
2256
+ requestAnimationFrame(() => {
2257
+ if (this.destroyed) return;
2258
+ this.cardEl.classList.remove("pocato-loading");
2259
+ this.cardEl.classList.add("pocato-ready");
2260
+ this.onReady?.();
2261
+ });
2107
2262
  }
2108
2263
  injectStyles() {
2109
2264
  const id = "pocato-styles";
@@ -2367,10 +2522,16 @@ var Renderer = class {
2367
2522
  this.intersectionObserver?.disconnect();
2368
2523
  cleanupPendingVideos();
2369
2524
  this.frontFace.destroy();
2370
- this.frontRenderer?.dispose();
2525
+ if (this.frontRenderer) {
2526
+ this.disposeWebGLRenderer(this.frontRenderer);
2527
+ this.frontRenderer = null;
2528
+ }
2371
2529
  if (this.backFace) {
2372
2530
  this.backFace.destroy();
2373
- this.backRenderer?.dispose();
2531
+ if (this.backRenderer) {
2532
+ this.disposeWebGLRenderer(this.backRenderer);
2533
+ this.backRenderer = null;
2534
+ }
2374
2535
  }
2375
2536
  if (this.cardEl.parentNode) {
2376
2537
  this.cardEl.parentNode.removeChild(this.cardEl);