VirtualKimi commited on
Commit
eaa5a03
·
verified ·
1 Parent(s): 930d92c

Upload kimi-utils.js

Browse files
Files changed (1) hide show
  1. kimi-js/kimi-utils.js +194 -4
kimi-js/kimi-utils.js CHANGED
@@ -370,6 +370,18 @@ class KimiVideoManager {
370
  this._stickyUntil = 0;
371
  this._pendingSwitches = [];
372
  this._debug = false;
 
 
 
 
 
 
 
 
 
 
 
 
373
  }
374
 
375
  /**
@@ -633,7 +645,30 @@ class KimiVideoManager {
633
  this.neutralVideos = this.videoCategories.neutral;
634
 
635
  const neutrals = this.neutralVideos || [];
636
- neutrals.slice(0, 2).forEach(src => this._prefetch(src));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
637
  }
638
 
639
  async init(database = null) {
@@ -642,6 +677,21 @@ class KimiVideoManager {
642
  this._visibilityHandler = this.onVisibilityChange.bind(this);
643
  document.addEventListener("visibilitychange", this._visibilityHandler);
644
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
645
  }
646
 
647
  onVisibilityChange() {
@@ -682,6 +732,7 @@ class KimiVideoManager {
682
  }
683
  this._stickyContext = null;
684
  this._stickyUntil = 0;
 
685
  }
686
  // While an emotion video is playing (speaking), block non-speaking context switches
687
  if (
@@ -1367,6 +1418,18 @@ class KimiVideoManager {
1367
  }
1368
 
1369
  loadAndSwitchVideo(videoSrc, priority = "normal") {
 
 
 
 
 
 
 
 
 
 
 
 
1370
  // Avoid redundant loading if the requested source is already active or currently loading in inactive element
1371
  const activeSrc = this.activeVideo?.querySelector("source")?.getAttribute("src");
1372
  const inactiveSrc = this.inactiveVideo?.querySelector("source")?.getAttribute("src");
@@ -1432,22 +1495,46 @@ class KimiVideoManager {
1432
  this.inactiveVideo.removeEventListener("canplay", this._currentLoadHandler);
1433
  this.inactiveVideo.removeEventListener("loadeddata", this._currentLoadHandler);
1434
  this.inactiveVideo.removeEventListener("error", this._currentErrorHandler);
 
 
 
 
 
 
 
1435
  this.performSwitch();
1436
  };
1437
  this._currentLoadHandler = onReady;
1438
 
1439
  const folder = getCharacterInfo(this.characterName).videoFolder;
1440
- const fallbackVideo = `${folder}neutral/neutral-gentle-breathing.mp4`;
 
 
 
 
 
 
 
1441
 
1442
  this._currentErrorHandler = e => {
1443
- console.warn(`Error loading video: ${videoSrc}, falling back to: ${fallbackVideo}`);
 
 
 
 
 
 
 
1444
  this._loadingInProgress = false;
1445
  if (this._loadTimeout) {
1446
  clearTimeout(this._loadTimeout);
1447
  this._loadTimeout = null;
1448
  }
 
 
1449
  if (videoSrc !== fallbackVideo) {
1450
  // Try fallback video
 
1451
  this.loadAndSwitchVideo(fallbackVideo, "high");
1452
  } else {
1453
  // Ultimate fallback: try any neutral video
@@ -1468,6 +1555,12 @@ class KimiVideoManager {
1468
  this._switchInProgress = false;
1469
  }
1470
  }
 
 
 
 
 
 
1471
  };
1472
 
1473
  this.inactiveVideo.addEventListener("loadeddata", this._currentLoadHandler, { once: true });
@@ -1478,15 +1571,36 @@ class KimiVideoManager {
1478
  queueMicrotask(() => onReady());
1479
  }
1480
 
 
 
 
 
 
 
 
 
1481
  this._loadTimeout = setTimeout(() => {
1482
  if (!fired) {
 
 
 
 
 
 
 
 
 
 
 
 
 
1483
  if (this.inactiveVideo.readyState >= 2) {
1484
  onReady();
1485
  } else {
1486
  this._currentErrorHandler();
1487
  }
1488
  }
1489
- }, 3000);
1490
  }
1491
 
1492
  usePreloadedVideo(preloadedVideo, videoSrc) {
@@ -1533,6 +1647,19 @@ class KimiVideoManager {
1533
  const src = this.activeVideo?.querySelector("source")?.getAttribute("src");
1534
  const info = { context: this.currentContext, emotion: this.currentEmotion };
1535
  console.log("🎬 VideoManager: Now playing:", src, info);
 
 
 
 
 
 
 
 
 
 
 
 
 
1536
  } catch {}
1537
  this._switchInProgress = false;
1538
  this.setupEventListenersForContext(this.currentContext);
@@ -1553,11 +1680,47 @@ class KimiVideoManager {
1553
  } else {
1554
  // Non-promise play fallback
1555
  this._switchInProgress = false;
 
 
 
 
 
 
 
 
 
 
1556
  this.setupEventListenersForContext(this.currentContext);
1557
  }
1558
  });
1559
  }
1560
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1561
  _prefetch(src) {
1562
  if (!src || this._prefetchCache.has(src) || this._prefetchInFlight.has(src)) return;
1563
  if (this._prefetchCache.size + this._prefetchInFlight.size >= this._maxPrefetch) return;
@@ -1575,10 +1738,12 @@ class KimiVideoManager {
1575
  };
1576
  v.oncanplay = () => {
1577
  this._prefetchCache.set(src, v);
 
1578
  cleanup();
1579
  };
1580
  v.oncanplaythrough = () => {
1581
  this._prefetchCache.set(src, v);
 
1582
  cleanup();
1583
  };
1584
  v.onerror = () => {
@@ -1589,6 +1754,31 @@ class KimiVideoManager {
1589
  } catch {}
1590
  }
1591
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1592
  _prefetchLikely(category) {
1593
  const list = this.videoCategories[category] || [];
1594
  // Prefetch 1-2 next likely videos different from current
 
370
  this._stickyUntil = 0;
371
  this._pendingSwitches = [];
372
  this._debug = false;
373
+ // Adaptive timeout refinements (A+B+C)
374
+ this._maxTimeout = 6000; // Reduced upper bound (was 10000) for 10s clips
375
+ this._timeoutExtension = 1200; // Extension when metadata only
376
+ this._timeoutCapRatio = 0.7; // Cap total wait <= 70% clip length
377
+ // Initialize adaptive loading metrics and failure tracking
378
+ this._avgLoadTime = null;
379
+ this._loadTimeSamples = [];
380
+ this._maxSamples = 10;
381
+ this._minTimeout = 3000;
382
+ this._recentFailures = new Map();
383
+ this._failureCooldown = 5000;
384
+ this._consecutiveErrorCount = 0;
385
  }
386
 
387
  /**
 
645
  this.neutralVideos = this.videoCategories.neutral;
646
 
647
  const neutrals = this.neutralVideos || [];
648
+ // Progressive warm-up phase: start with only 2 neutrals (adaptive on network), others scheduled later
649
+ let neutralPrefetchCount = 2;
650
+ try {
651
+ const conn = navigator.connection || navigator.webkitConnection || navigator.mozConnection;
652
+ if (conn && conn.effectiveType) {
653
+ // Reduce on slower connections
654
+ if (/2g/i.test(conn.effectiveType)) neutralPrefetchCount = 1;
655
+ else if (/3g/i.test(conn.effectiveType)) neutralPrefetchCount = 2;
656
+ }
657
+ } catch {}
658
+ neutrals.slice(0, neutralPrefetchCount).forEach(src => this._prefetch(src));
659
+
660
+ // Schedule warm-up step 2: after 5s prefetch the 3rd neutral if not already cached
661
+ if (!this._warmupTimer) {
662
+ this._warmupTimer = setTimeout(() => {
663
+ try {
664
+ const target = neutrals[2];
665
+ if (target && !this._prefetchCache.has(target)) this._prefetch(target);
666
+ } catch {}
667
+ }, 5000);
668
+ }
669
+
670
+ // Mark waiting for first interaction to fetch 4th neutral later
671
+ this._awaitingFirstInteraction = true;
672
  }
673
 
674
  async init(database = null) {
 
677
  this._visibilityHandler = this.onVisibilityChange.bind(this);
678
  document.addEventListener("visibilitychange", this._visibilityHandler);
679
  }
680
+ // Hook basic user interaction (first click / keypress) to advance warm-up
681
+ if (!this._firstInteractionHandler) {
682
+ this._firstInteractionHandler = () => {
683
+ if (this._awaitingFirstInteraction) {
684
+ this._awaitingFirstInteraction = false;
685
+ try {
686
+ const neutrals = this.neutralVideos || [];
687
+ const fourth = neutrals[3];
688
+ if (fourth && !this._prefetchCache.has(fourth)) this._prefetch(fourth);
689
+ } catch {}
690
+ }
691
+ };
692
+ window.addEventListener("click", this._firstInteractionHandler, { once: true });
693
+ window.addEventListener("keydown", this._firstInteractionHandler, { once: true });
694
+ }
695
  }
696
 
697
  onVisibilityChange() {
 
732
  }
733
  this._stickyContext = null;
734
  this._stickyUntil = 0;
735
+ // Do not reset adaptive loading metrics here; preserve rolling stats across sticky context release
736
  }
737
  // While an emotion video is playing (speaking), block non-speaking context switches
738
  if (
 
1418
  }
1419
 
1420
  loadAndSwitchVideo(videoSrc, priority = "normal") {
1421
+ const startTs = performance.now();
1422
+ // Guard: ignore if recently failed and still in cooldown
1423
+ const lastFail = this._recentFailures.get(videoSrc);
1424
+ if (lastFail && performance.now() - lastFail < this._failureCooldown) {
1425
+ // Pick an alternative neutral as quick substitution
1426
+ const neutralList = (this.videoCategories && this.videoCategories.neutral) || [];
1427
+ const alt = neutralList.find(v => v !== videoSrc) || neutralList[0];
1428
+ if (alt && alt !== videoSrc) {
1429
+ console.warn(`Skipping recently failed video (cooldown): ${videoSrc} -> trying alt: ${alt}`);
1430
+ return this.loadAndSwitchVideo(alt, priority);
1431
+ }
1432
+ }
1433
  // Avoid redundant loading if the requested source is already active or currently loading in inactive element
1434
  const activeSrc = this.activeVideo?.querySelector("source")?.getAttribute("src");
1435
  const inactiveSrc = this.inactiveVideo?.querySelector("source")?.getAttribute("src");
 
1495
  this.inactiveVideo.removeEventListener("canplay", this._currentLoadHandler);
1496
  this.inactiveVideo.removeEventListener("loadeddata", this._currentLoadHandler);
1497
  this.inactiveVideo.removeEventListener("error", this._currentErrorHandler);
1498
+ // Update rolling average load time
1499
+ const duration = performance.now() - startTs;
1500
+ this._loadTimeSamples.push(duration);
1501
+ if (this._loadTimeSamples.length > this._maxSamples) this._loadTimeSamples.shift();
1502
+ const sum = this._loadTimeSamples.reduce((a, b) => a + b, 0);
1503
+ this._avgLoadTime = sum / this._loadTimeSamples.length;
1504
+ this._consecutiveErrorCount = 0; // reset on success
1505
  this.performSwitch();
1506
  };
1507
  this._currentLoadHandler = onReady;
1508
 
1509
  const folder = getCharacterInfo(this.characterName).videoFolder;
1510
+ // Rotating fallback pool (stable neutrals first positions)
1511
+ if (!this._fallbackPool) {
1512
+ const neutralList = (this.videoCategories && this.videoCategories.neutral) || [];
1513
+ // Choose first 3 as "ultra reliable" (order curated manually in list)
1514
+ this._fallbackPool = neutralList.slice(0, 3);
1515
+ this._fallbackIndex = 0;
1516
+ }
1517
+ const fallbackVideo = this._fallbackPool[this._fallbackIndex % this._fallbackPool.length];
1518
 
1519
  this._currentErrorHandler = e => {
1520
+ const mediaEl = this.inactiveVideo;
1521
+ const readyState = mediaEl ? mediaEl.readyState : -1;
1522
+ const networkState = mediaEl ? mediaEl.networkState : -1;
1523
+ let mediaErrorCode = null;
1524
+ if (mediaEl && mediaEl.error) mediaErrorCode = mediaEl.error.code;
1525
+ console.warn(
1526
+ `Error loading video: ${videoSrc} (readyState=${readyState} networkState=${networkState} mediaError=${mediaErrorCode}) falling back to: ${fallbackVideo}`
1527
+ );
1528
  this._loadingInProgress = false;
1529
  if (this._loadTimeout) {
1530
  clearTimeout(this._loadTimeout);
1531
  this._loadTimeout = null;
1532
  }
1533
+ this._recentFailures.set(videoSrc, performance.now());
1534
+ this._consecutiveErrorCount++;
1535
  if (videoSrc !== fallbackVideo) {
1536
  // Try fallback video
1537
+ this._fallbackIndex = (this._fallbackIndex + 1) % this._fallbackPool.length; // advance for next time
1538
  this.loadAndSwitchVideo(fallbackVideo, "high");
1539
  } else {
1540
  // Ultimate fallback: try any neutral video
 
1555
  this._switchInProgress = false;
1556
  }
1557
  }
1558
+ // Escalate diagnostics if many consecutive errors
1559
+ if (this._consecutiveErrorCount >= 3) {
1560
+ console.info(
1561
+ `Diagnostics: avgLoadTime=${this._avgLoadTime?.toFixed(1) || "n/a"}ms samples=${this._loadTimeSamples.length} prefetchCache=${this._prefetchCache.size}`
1562
+ );
1563
+ }
1564
  };
1565
 
1566
  this.inactiveVideo.addEventListener("loadeddata", this._currentLoadHandler, { once: true });
 
1571
  queueMicrotask(() => onReady());
1572
  }
1573
 
1574
+ // Dynamic timeout: refined formula avg*1.5 + buffer, bounded
1575
+ let adaptiveTimeout = this._minTimeout;
1576
+ if (this._avgLoadTime) {
1577
+ adaptiveTimeout = Math.min(this._maxTimeout, Math.max(this._minTimeout, this._avgLoadTime * 1.5 + 400));
1578
+ }
1579
+ // Cap by clip length ratio if we know (assume 10000ms default when metadata absent)
1580
+ const currentClipMs = 10000; // All clips are 10s
1581
+ adaptiveTimeout = Math.min(adaptiveTimeout, Math.floor(currentClipMs * this._timeoutCapRatio));
1582
  this._loadTimeout = setTimeout(() => {
1583
  if (!fired) {
1584
+ // If metadata is there but not canplay yet, extend once
1585
+ if (this.inactiveVideo.readyState >= 1 && this.inactiveVideo.readyState < 2) {
1586
+ console.debug(
1587
+ `Extending timeout for ${videoSrc} (readyState=${this.inactiveVideo.readyState}) by ${this._timeoutExtension}ms`
1588
+ );
1589
+ this._loadTimeout = setTimeout(() => {
1590
+ if (!fired) {
1591
+ if (this.inactiveVideo.readyState >= 2) onReady();
1592
+ else this._currentErrorHandler();
1593
+ }
1594
+ }, this._timeoutExtension);
1595
+ return;
1596
+ }
1597
  if (this.inactiveVideo.readyState >= 2) {
1598
  onReady();
1599
  } else {
1600
  this._currentErrorHandler();
1601
  }
1602
  }
1603
+ }, adaptiveTimeout);
1604
  }
1605
 
1606
  usePreloadedVideo(preloadedVideo, videoSrc) {
 
1647
  const src = this.activeVideo?.querySelector("source")?.getAttribute("src");
1648
  const info = { context: this.currentContext, emotion: this.currentEmotion };
1649
  console.log("🎬 VideoManager: Now playing:", src, info);
1650
+ // Recompute autoTransitionDuration from actual duration if available (C)
1651
+ try {
1652
+ const d = this.activeVideo.duration;
1653
+ if (!isNaN(d) && d > 0.5) {
1654
+ // Keep 1s headroom before natural end for auto scheduling
1655
+ const target = Math.max(1000, d * 1000 - 1100);
1656
+ this.autoTransitionDuration = target;
1657
+ } else {
1658
+ this.autoTransitionDuration = 9900; // fallback for 10s clips
1659
+ }
1660
+ // Dynamic neutral prefetch to widen diversity without burst
1661
+ this._prefetchNeutralDynamic();
1662
+ } catch {}
1663
  } catch {}
1664
  this._switchInProgress = false;
1665
  this.setupEventListenersForContext(this.currentContext);
 
1680
  } else {
1681
  // Non-promise play fallback
1682
  this._switchInProgress = false;
1683
+ try {
1684
+ const d = this.activeVideo.duration;
1685
+ if (!isNaN(d) && d > 0.5) {
1686
+ const target = Math.max(1000, d * 1000 - 1100);
1687
+ this.autoTransitionDuration = target;
1688
+ } else {
1689
+ this.autoTransitionDuration = 9900;
1690
+ }
1691
+ this._prefetchNeutralDynamic();
1692
+ } catch {}
1693
  this.setupEventListenersForContext(this.currentContext);
1694
  }
1695
  });
1696
  }
1697
 
1698
+ _prefetchNeutralDynamic() {
1699
+ try {
1700
+ const neutrals = (this.videoCategories && this.videoCategories.neutral) || [];
1701
+ if (!neutrals.length) return;
1702
+ // Build a set of already cached or in-flight
1703
+ const cached = new Set(
1704
+ [...this._prefetchCache.keys(), ...this._prefetchInFlight.values()].map(v => (typeof v === "string" ? v : v?.src))
1705
+ ); // defensive
1706
+ const current = this.activeVideo?.querySelector("source")?.getAttribute("src");
1707
+ // Choose up to 2 unseen neutral videos different from current
1708
+ const candidates = neutrals.filter(s => s && s !== current && !cached.has(s));
1709
+ if (!candidates.length) return;
1710
+ let limit = 2;
1711
+ // Network-aware limiting
1712
+ try {
1713
+ const conn = navigator.connection || navigator.webkitConnection || navigator.mozConnection;
1714
+ if (conn && conn.effectiveType) {
1715
+ if (/2g/i.test(conn.effectiveType)) limit = 0;
1716
+ else if (/3g/i.test(conn.effectiveType)) limit = 1;
1717
+ }
1718
+ } catch {}
1719
+ if (limit <= 0) return;
1720
+ candidates.slice(0, limit).forEach(src => this._prefetch(src));
1721
+ } catch {}
1722
+ }
1723
+
1724
  _prefetch(src) {
1725
  if (!src || this._prefetchCache.has(src) || this._prefetchInFlight.has(src)) return;
1726
  if (this._prefetchCache.size + this._prefetchInFlight.size >= this._maxPrefetch) return;
 
1738
  };
1739
  v.oncanplay = () => {
1740
  this._prefetchCache.set(src, v);
1741
+ this._trimPrefetchCacheIfNeeded();
1742
  cleanup();
1743
  };
1744
  v.oncanplaythrough = () => {
1745
  this._prefetchCache.set(src, v);
1746
+ this._trimPrefetchCacheIfNeeded();
1747
  cleanup();
1748
  };
1749
  v.onerror = () => {
 
1754
  } catch {}
1755
  }
1756
 
1757
+ _trimPrefetchCacheIfNeeded() {
1758
+ try {
1759
+ // Only apply LRU trimming to neutral videos; cap at 6 neutrals cached
1760
+ const MAX_NEUTRAL = 6;
1761
+ const entries = [...this._prefetchCache.entries()];
1762
+ const neutralEntries = entries.filter(([src]) => /\/neutral\//.test(src));
1763
+ if (neutralEntries.length <= MAX_NEUTRAL) return;
1764
+ // LRU heuristic: older insertion first (Map preserves insertion order)
1765
+ const excess = neutralEntries.length - MAX_NEUTRAL;
1766
+ let removed = 0;
1767
+ for (const [src, vid] of neutralEntries) {
1768
+ if (removed >= excess) break;
1769
+ // Avoid removing currently active or about to be used
1770
+ const current = this.activeVideo?.querySelector("source")?.getAttribute("src");
1771
+ if (src === current) continue;
1772
+ this._prefetchCache.delete(src);
1773
+ try {
1774
+ vid.removeAttribute("src");
1775
+ vid.load();
1776
+ } catch {}
1777
+ removed++;
1778
+ }
1779
+ } catch {}
1780
+ }
1781
+
1782
  _prefetchLikely(category) {
1783
  const list = this.videoCategories[category] || [];
1784
  // Prefetch 1-2 next likely videos different from current