@sangwonl/pocato-core 0.4.3 → 0.4.5

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";
@@ -430,30 +457,47 @@ function loadVideoTexture(src, onError) {
430
457
  video.style.zIndex = "-9999";
431
458
  document.body.appendChild(video);
432
459
  pendingVideos.add(video);
460
+ let settled = false;
433
461
  video.addEventListener("error", () => {
462
+ if (settled) return;
463
+ settled = true;
434
464
  pendingVideos.delete(video);
435
465
  const msg = video.error?.message ?? "Unknown video error";
436
- onError?.(new Error(`Failed to load video texture: ${src} (${msg})`));
466
+ onError?.(new Error(`Failed to load video texture: ${layer.src} (${msg})`));
437
467
  video.remove();
438
468
  resolve({ texture: getTransparentTexture() });
439
469
  }, { once: true });
440
- video.play().then(() => {
470
+ video.addEventListener("loadeddata", () => {
471
+ if (settled) return;
472
+ settled = true;
473
+ video.pause();
441
474
  pendingVideos.delete(video);
442
475
  const texture = new THREE2.VideoTexture(video);
443
- resolve({ texture, videoEl: video });
444
- }).catch(() => {
445
- pendingVideos.delete(video);
446
- onError?.(new Error(`Video autoplay blocked: ${src}`));
447
- video.remove();
448
- resolve({ texture: getTransparentTexture() });
449
- });
476
+ resolve({
477
+ texture,
478
+ videoEl: video,
479
+ play: () => {
480
+ video.play().catch(() => {
481
+ onError?.(new Error(`Video autoplay blocked: ${layer.src}`));
482
+ });
483
+ }
484
+ });
485
+ }, { once: true });
486
+ video.src = layer.src;
487
+ video.load();
450
488
  });
451
489
  }
452
- function loadFrozenVideoTexture(src, freezeTime, onError) {
490
+ function loadFrozenVideoTexture(layer, freezeTime, onError) {
453
491
  return new Promise((resolve) => {
492
+ debugLog("freeze load start", {
493
+ src: layer.src,
494
+ freezeTime,
495
+ freezeSeed: layer.freezeSeed
496
+ });
454
497
  const video = document.createElement("video");
455
- video.src = src;
498
+ video.crossOrigin = "anonymous";
456
499
  video.muted = true;
500
+ applyVideoPlaybackOptions(video, layer);
457
501
  video.playsInline = true;
458
502
  video.preload = "auto";
459
503
  video.style.position = "fixed";
@@ -464,31 +508,137 @@ function loadFrozenVideoTexture(src, freezeTime, onError) {
464
508
  video.style.zIndex = "-9999";
465
509
  document.body.appendChild(video);
466
510
  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", () => {
511
+ let settled = false;
512
+ let targetTime = 0;
513
+ let retrySeekRegistered = false;
514
+ const captureFrame = () => {
515
+ if (settled) return;
516
+ settled = true;
517
+ video.pause();
518
+ debugLog("freeze capture frame", {
519
+ src: layer.src,
520
+ currentTime: video.currentTime,
521
+ duration: video.duration,
522
+ readyState: video.readyState,
523
+ videoWidth: video.videoWidth,
524
+ videoHeight: video.videoHeight
525
+ });
479
526
  const canvas = document.createElement("canvas");
480
- canvas.width = video.videoWidth;
481
- canvas.height = video.videoHeight;
527
+ canvas.width = video.videoWidth || 1;
528
+ canvas.height = video.videoHeight || 1;
482
529
  const ctx = canvas.getContext("2d");
483
- ctx.drawImage(video, 0, 0);
530
+ if (!ctx) {
531
+ pendingVideos.delete(video);
532
+ video.src = "";
533
+ video.remove();
534
+ resolve({ texture: getTransparentTexture() });
535
+ return;
536
+ }
537
+ try {
538
+ ctx.drawImage(video, 0, 0);
539
+ } catch (error) {
540
+ debugLog("freeze drawImage failed", {
541
+ src: layer.src,
542
+ error: error instanceof Error ? error.message : String(error)
543
+ });
544
+ pendingVideos.delete(video);
545
+ video.src = "";
546
+ video.remove();
547
+ onError?.(error instanceof Error ? error : new Error(String(error)));
548
+ resolve({ texture: getTransparentTexture() });
549
+ return;
550
+ }
484
551
  const texture = new THREE2.CanvasTexture(canvas);
485
552
  texture.needsUpdate = true;
553
+ debugLog("freeze canvas texture ready", {
554
+ src: layer.src,
555
+ width: canvas.width,
556
+ height: canvas.height
557
+ });
486
558
  pendingVideos.delete(video);
487
- video.pause();
488
559
  video.src = "";
489
560
  video.remove();
490
561
  resolve({ texture });
562
+ };
563
+ const captureReadyFrame = () => {
564
+ debugLog("freeze wait decoded frame", {
565
+ src: layer.src,
566
+ currentTime: video.currentTime,
567
+ readyState: video.readyState
568
+ });
569
+ if ("requestVideoFrameCallback" in video) {
570
+ video.requestVideoFrameCallback(() => captureFrame());
571
+ }
572
+ const playPromise = video.play();
573
+ playPromise.catch(() => {
574
+ debugLog("freeze play rejected, fallback capture", { src: layer.src });
575
+ window.setTimeout(captureFrame, 0);
576
+ });
577
+ if (!("requestVideoFrameCallback" in video)) {
578
+ window.setTimeout(captureFrame, 0);
579
+ }
580
+ };
581
+ const retrySeekWhenReady = () => {
582
+ if (settled) return;
583
+ if (retrySeekRegistered) return;
584
+ retrySeekRegistered = true;
585
+ const retry = () => {
586
+ retrySeekRegistered = false;
587
+ if (settled) return;
588
+ debugLog("freeze retry seek", {
589
+ src: layer.src,
590
+ targetTime,
591
+ currentTime: video.currentTime,
592
+ duration: video.duration,
593
+ readyState: video.readyState
594
+ });
595
+ video.currentTime = targetTime;
596
+ };
597
+ video.addEventListener("durationchange", retry, { once: true });
598
+ video.addEventListener("canplay", retry, { once: true });
599
+ video.addEventListener("loadeddata", retry, { once: true });
600
+ };
601
+ video.addEventListener("error", () => {
602
+ pendingVideos.delete(video);
603
+ const msg = video.error?.message ?? "Unknown video error";
604
+ onError?.(new Error(`Failed to load video texture: ${layer.src} (${msg})`));
605
+ video.remove();
606
+ resolve({ texture: getTransparentTexture() });
491
607
  }, { once: true });
608
+ video.addEventListener("loadedmetadata", () => {
609
+ const time = getFreezeTargetTime(freezeTime, video.duration, layer.freezeSeed);
610
+ targetTime = time;
611
+ debugLog("freeze metadata loaded", {
612
+ src: layer.src,
613
+ targetTime: time,
614
+ duration: video.duration,
615
+ readyState: video.readyState
616
+ });
617
+ if (isAtTargetTime(video, time)) {
618
+ if (video.readyState >= 2) {
619
+ captureReadyFrame();
620
+ } else {
621
+ video.addEventListener("loadeddata", () => captureReadyFrame(), { once: true });
622
+ }
623
+ return;
624
+ }
625
+ video.currentTime = time;
626
+ }, { once: true });
627
+ video.addEventListener("seeked", () => {
628
+ debugLog("freeze seeked", {
629
+ src: layer.src,
630
+ targetTime,
631
+ currentTime: video.currentTime,
632
+ duration: video.duration,
633
+ readyState: video.readyState
634
+ });
635
+ if (!isAtTargetTime(video, targetTime)) {
636
+ retrySeekWhenReady();
637
+ return;
638
+ }
639
+ captureReadyFrame();
640
+ });
641
+ video.src = layer.src;
492
642
  video.load();
493
643
  });
494
644
  }
@@ -1823,6 +1973,7 @@ var MAX_LAYERS = 8;
1823
1973
  var FaceRenderer = class {
1824
1974
  constructor(shader, width, height, effectUniforms) {
1825
1975
  this.loadedLayers = [];
1976
+ this.layerLoadVersion = 0;
1826
1977
  bootstrapShaders();
1827
1978
  this.scene = new THREE3.Scene();
1828
1979
  this.camera = new THREE3.Camera();
@@ -1872,6 +2023,7 @@ var FaceRenderer = class {
1872
2023
  }
1873
2024
  }
1874
2025
  async loadLayers(layers, onError) {
2026
+ const version = ++this.layerLoadVersion;
1875
2027
  this.disposeLayers();
1876
2028
  const count = Math.min(layers.length, MAX_LAYERS);
1877
2029
  this.material.uniforms.uLayerCount.value = count;
@@ -1879,6 +2031,12 @@ var FaceRenderer = class {
1879
2031
  (layer) => loadLayerTexture(layer, onError)
1880
2032
  );
1881
2033
  const results = await Promise.all(promises);
2034
+ if (version !== this.layerLoadVersion) {
2035
+ for (const loaded of results) {
2036
+ disposeLoadedLayer(loaded);
2037
+ }
2038
+ return;
2039
+ }
1882
2040
  this.loadedLayers = results;
1883
2041
  for (let i = 0; i < results.length; i++) {
1884
2042
  this.material.uniforms[`uLayer${i}`].value = results[i].texture;
@@ -1898,6 +2056,11 @@ var FaceRenderer = class {
1898
2056
  updateResolution(width, height) {
1899
2057
  this.material.uniforms.uResolution.value.set(width, height);
1900
2058
  }
2059
+ playLoadedVideos() {
2060
+ for (const loaded of this.loadedLayers) {
2061
+ loaded.play?.();
2062
+ }
2063
+ }
1901
2064
  updateShader(fragmentShader) {
1902
2065
  this.material.fragmentShader = resolveIncludes(fragmentShader);
1903
2066
  this.material.needsUpdate = true;
@@ -1914,6 +2077,7 @@ var FaceRenderer = class {
1914
2077
  this.material.uniforms.uLayerCount.value = 0;
1915
2078
  }
1916
2079
  destroy() {
2080
+ this.layerLoadVersion++;
1917
2081
  this.disposeLayers();
1918
2082
  this.mesh.geometry.dispose();
1919
2083
  this.material.dispose();
@@ -1933,11 +2097,12 @@ function toThreeUniformValue(value) {
1933
2097
 
1934
2098
  // src/renderer/index.ts
1935
2099
  var Renderer = class {
1936
- constructor(container, options, onError, onReady) {
2100
+ constructor(container, options, onError, onReady, onFirstFrame) {
1937
2101
  this.container = container;
1938
2102
  this.options = options;
1939
2103
  this.onError = onError;
1940
2104
  this.onReady = onReady;
2105
+ this.onFirstFrame = onFirstFrame;
1941
2106
  this.backFace = null;
1942
2107
  this.frontRenderer = null;
1943
2108
  this.backRenderer = null;
@@ -1948,6 +2113,9 @@ var Renderer = class {
1948
2113
  this.intersectionObserver = null;
1949
2114
  this.visible = false;
1950
2115
  this.destroyed = false;
2116
+ this.layersReady = false;
2117
+ this.firstFrameEmitted = false;
2118
+ this.firstFrameScheduled = false;
1951
2119
  if (!options.front?.layers?.length) {
1952
2120
  console.warn("[pocato] front.layers must have at least 1 element");
1953
2121
  }
@@ -2018,10 +2186,10 @@ var Renderer = class {
2018
2186
  this.loadAllLayers();
2019
2187
  this.setupResizeObserver();
2020
2188
  this.setupIntersectionObserver();
2021
- requestAnimationFrame(() => {
2022
- this.cardEl.classList.remove("pocato-loading");
2023
- this.cardEl.classList.add("pocato-ready");
2024
- });
2189
+ }
2190
+ disposeWebGLRenderer(renderer) {
2191
+ renderer.forceContextLoss();
2192
+ renderer.dispose();
2025
2193
  }
2026
2194
  createFrontRenderer() {
2027
2195
  if (this.frontRenderer) return;
@@ -2052,7 +2220,7 @@ var Renderer = class {
2052
2220
  disposeFrontRenderer() {
2053
2221
  if (!this.frontRenderer) return;
2054
2222
  this.frontRenderer.render(this.frontFace.scene, this.frontFace.camera);
2055
- this.frontRenderer.dispose();
2223
+ this.disposeWebGLRenderer(this.frontRenderer);
2056
2224
  this.frontRenderer = null;
2057
2225
  }
2058
2226
  disposeBackRenderer() {
@@ -2060,7 +2228,7 @@ var Renderer = class {
2060
2228
  if (this.backFace) {
2061
2229
  this.backRenderer.render(this.backFace.scene, this.backFace.camera);
2062
2230
  }
2063
- this.backRenderer.dispose();
2231
+ this.disposeWebGLRenderer(this.backRenderer);
2064
2232
  this.backRenderer = null;
2065
2233
  }
2066
2234
  activate() {
@@ -2103,8 +2271,26 @@ var Renderer = class {
2103
2271
  promises.push(this.backFace.loadLayers(this.options.back.layers, this.onError));
2104
2272
  }
2105
2273
  await Promise.all(promises);
2274
+ if (this.destroyed) return;
2275
+ this.layersReady = true;
2106
2276
  this.onReady?.();
2107
2277
  }
2278
+ scheduleFirstFrame() {
2279
+ if (!this.layersReady || this.firstFrameEmitted || this.firstFrameScheduled) return;
2280
+ this.firstFrameScheduled = true;
2281
+ requestAnimationFrame(() => {
2282
+ if (this.destroyed || this.firstFrameEmitted) return;
2283
+ this.firstFrameEmitted = true;
2284
+ this.cardEl.classList.remove("pocato-loading");
2285
+ this.cardEl.classList.add("pocato-ready");
2286
+ this.onFirstFrame?.();
2287
+ requestAnimationFrame(() => {
2288
+ if (this.destroyed) return;
2289
+ this.frontFace.playLoadedVideos();
2290
+ this.backFace?.playLoadedVideos();
2291
+ });
2292
+ });
2293
+ }
2108
2294
  injectStyles() {
2109
2295
  const id = "pocato-styles";
2110
2296
  if (document.getElementById(id)) return;
@@ -2274,6 +2460,7 @@ var Renderer = class {
2274
2460
  this.backFace.material.uniforms.uTime.value += delta;
2275
2461
  this.backRenderer.render(this.backFace.scene, this.backFace.camera);
2276
2462
  }
2463
+ this.scheduleFirstFrame();
2277
2464
  };
2278
2465
  this.rafId = requestAnimationFrame(animate);
2279
2466
  }
@@ -2367,10 +2554,16 @@ var Renderer = class {
2367
2554
  this.intersectionObserver?.disconnect();
2368
2555
  cleanupPendingVideos();
2369
2556
  this.frontFace.destroy();
2370
- this.frontRenderer?.dispose();
2557
+ if (this.frontRenderer) {
2558
+ this.disposeWebGLRenderer(this.frontRenderer);
2559
+ this.frontRenderer = null;
2560
+ }
2371
2561
  if (this.backFace) {
2372
2562
  this.backFace.destroy();
2373
- this.backRenderer?.dispose();
2563
+ if (this.backRenderer) {
2564
+ this.disposeWebGLRenderer(this.backRenderer);
2565
+ this.backRenderer = null;
2566
+ }
2374
2567
  }
2375
2568
  if (this.cardEl.parentNode) {
2376
2569
  this.cardEl.parentNode.removeChild(this.cardEl);
@@ -2956,7 +3149,8 @@ var PocaCard = class extends EventEmitter {
2956
3149
  preventTouchScroll: options.preventTouchScroll
2957
3150
  },
2958
3151
  (error) => this.emit("error", error),
2959
- () => this.emit("ready")
3152
+ () => this.emit("ready"),
3153
+ () => this.emit("firstFrame")
2960
3154
  );
2961
3155
  this.interaction = new InteractionHandler(
2962
3156
  this.renderer.getRotatorEl(),