LFM2.5-VL-1.6B-WebGPU / index.html
shubeydoo's picture
Liquid AI LFM2.5-VL-1.6B-WebGPU Demo
e10c945
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LFM2.5 VL 1B Demo</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Loading Screen -->
<div id="loading-screen" class="loading-screen">
<!-- Animated Background Canvas -->
<canvas id="loading-canvas" class="loading-canvas"></canvas>
<!-- Vignette Overlay -->
<div class="loading-vignette"></div>
<!-- Main Content -->
<div class="loading-content">
<div class="loading-header" style="display: flex; justify-content: center; align-items: center;">
<img
src="assets/liquid-ai.svg"
alt="Liquid AI"
class="loading-logo"
style="
width: min(16vw, 128px);
height: min(16vw, 128px);
max-width: 40vw;
max-height: 40vw;
min-width: 64px;
min-height: 64px;
object-fit: contain;
transition: width 0.2s, height 0.2s;
">
</div>
<div class="loading-title-section">
<h1 class="loading-title">LFM2.5-VL-1.6B WebGPU</h1>
<p class="loading-subtitle">Vision-Language Model in Your Browser</p>
</div>
<div class="loading-description">
<p>This demo showcases in-browser vision-language inference with LFM2.5-VL-1.6B, powered by ONNX Runtime and WebGPU.</p>
<p>Everything runs entirely in your browser with WebGPU acceleration, meaning no data is sent to a server.</p>
</div>
<div class="loading-action-section">
<button id="loading-explore-btn" class="loading-explore-button">
<span id="loading-btn-text">Explore</span>
<span id="loading-spinner" class="loading-spinner hidden"></span>
<span id="loading-progress-text" class="loading-progress-text hidden"></span>
</button>
</div>
<div id="loading-error" class="loading-error hidden">
<p id="loading-error-text"></p>
<button id="loading-retry-btn" class="loading-retry-button">Retry</button>
</div>
</div>
</div>
<div class="app-layout">
<!-- Top Navigation -->
<div class="top-nav">
<div class="nav-left">
<img src="assets/liquid-ai.svg" alt="Liquid AI" class="nav-logo-img">
<a href="https://www.liquid.ai" target="_blank" rel="noopener noreferrer" class="nav-logo-link">Liquid</a>
</div>
<div class="nav-center">
<span class="nav-title">LFM2.5-VL-1.6B</span>
<span class="nav-subtitle">Stream from your webcam with real-time captions,powered by your own hardware with WebGPU!</span>
</div>
</div>
<!-- Main Content Area -->
<div class="container">
<!-- Live Caption Mode -->
<div id="live-caption-mode" class="mode-container active">
<div class="live-caption-content">
<div class="live-caption-video-section">
<div class="live-caption-video-container">
<video id="live-caption-video" autoplay></video>
<!-- Capture Overlay (on video) -->
<div class="capture-overlay">
<button id="start-live-caption-btn" class="control-btn primary">Start</button>
<div class="overlay-field">
<label class="overlay-label">Input:</label>
<select id="live-caption-resolution-select" class="control-select">
<option value="256">256px</option>
<option value="384" selected>384px</option>
<option value="448">448px</option>
<option value="512">512px</option>
</select>
</div>
<div class="capture-status">
<span class="status-indicator" id="live-status-indicator"></span>
<span class="status-text" id="live-status-text">Idle</span>
</div>
</div>
</div>
<div class="controls-bar">
<!-- Status Row -->
<div class="controls-row status-row">
<span class="model-status" id="model-status"></span>
<!-- Progress Bar (shown during loading) -->
<div class="progress-bar-row" id="loading-progress" style="display: none;">
<div class="progress-fill" id="progress-fill" style="width: 0%"></div>
</div>
</div>
<!-- Controls Row -->
<div class="controls-row">
<div class="control-group model-group">
<label class="control-label">Select quantization:</label>
<select id="model-select" class="control-select model-select">
<!-- Options populated from config.js -->
</select>
<button class="control-btn" id="reload-model-btn" title="Load Model">Load</button>
</div>
<div class="control-group cache-group">
<span class="cache-info" id="cache-info">0 MB</span>
<button class="control-btn small" id="clear-cache-btn" disabled title="Clear Cache">Clear</button>
</div>
</div>
</div>
</div>
<div class="live-caption-text-section">
<h3 class="caption-section-title">Captions</h3>
<div class="latest-caption" id="latest-caption">Start capturing to see live captions...</div>
<div class="caption-history" id="caption-history"></div>
</div>
</div>
</div>
</div>
</div>
<script>
// ============================================
// LOADING SCREEN
// ============================================
let loadingScreenVisible = true;
let loadingCanvas = null;
let loadingCtx = null;
let loadingDots = [];
let loadingAnimationId = null;
function initLoadingScreen() {
// Initialize canvas animation
loadingCanvas = document.getElementById('loading-canvas');
if (!loadingCanvas) return;
loadingCtx = loadingCanvas.getContext('2d');
setupLoadingCanvas();
animateLoadingCanvas();
// Set up event listeners
setupLoadingScreenListeners();
// Handle window resize
window.addEventListener('resize', setupLoadingCanvas);
}
function setupLoadingCanvas() {
if (!loadingCanvas || !loadingCtx) return;
loadingCanvas.width = window.innerWidth;
loadingCanvas.height = window.innerHeight;
// Create dots
loadingDots = [];
const numDots = Math.floor((loadingCanvas.width * loadingCanvas.height) / 15000);
for (let i = 0; i < numDots; i++) {
loadingDots.push({
x: Math.random() * loadingCanvas.width,
y: Math.random() * loadingCanvas.height,
radius: Math.random() * 1.5 + 0.5,
speed: Math.random() * 0.5 + 0.1,
opacity: Math.random() * 0.5 + 0.2,
blur: Math.random() > 0.7 ? Math.random() * 2 + 1 : 0
});
}
}
function animateLoadingCanvas() {
if (!loadingCtx || !loadingCanvas) return;
loadingCtx.clearRect(0, 0, loadingCanvas.width, loadingCanvas.height);
loadingDots.forEach(dot => {
// Update position
dot.y += dot.speed;
if (dot.y > loadingCanvas.height) {
dot.y = 0 - dot.radius;
dot.x = Math.random() * loadingCanvas.width;
}
// Draw dot
loadingCtx.beginPath();
loadingCtx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
loadingCtx.fillStyle = `rgba(255, 255, 255, ${dot.opacity})`;
if (dot.blur > 0) {
loadingCtx.filter = `blur(${dot.blur}px)`;
}
loadingCtx.fill();
loadingCtx.filter = 'none';
});
loadingAnimationId = requestAnimationFrame(animateLoadingCanvas);
}
function setupLoadingScreenListeners() {
// Explore button
const exploreBtn = document.getElementById('loading-explore-btn');
if (exploreBtn) {
exploreBtn.addEventListener('click', handleLoadingScreenLoad);
}
// Retry button
const retryBtn = document.getElementById('loading-retry-btn');
if (retryBtn) {
retryBtn.addEventListener('click', handleLoadingScreenLoad);
}
}
async function handleLoadingScreenLoad() {
const exploreBtn = document.getElementById('loading-explore-btn');
if (!exploreBtn) return;
// Simply hide the loading screen - user can load model manually via the input field
hideLoadingScreen();
}
function hideLoadingScreen() {
const screen = document.getElementById('loading-screen');
if (screen) {
screen.classList.add('hidden');
loadingScreenVisible = false;
// Stop canvas animation
if (loadingAnimationId) {
cancelAnimationFrame(loadingAnimationId);
loadingAnimationId = null;
}
// Clear any URL hash from old bookmarks/links
if (window.location.hash) {
history.replaceState(null, '', window.location.pathname);
}
}
}
function showLoadingScreen() {
const screen = document.getElementById('loading-screen');
if (screen) {
screen.classList.remove('hidden');
loadingScreenVisible = true;
// Restart canvas animation if needed
if (!loadingAnimationId && loadingCanvas) {
animateLoadingCanvas();
}
}
}
function isMobileDevice() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|| (navigator.maxTouchPoints > 0 && window.innerWidth < 1024);
}
function showMobileWarning() {
if (!isMobileDevice()) return;
const warningDiv = document.createElement('div');
warningDiv.className = 'mobile-warning';
warningDiv.innerHTML = `
<div class="mobile-warning-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
Mobile Device Detected
</div>
<p>This demo requires a desktop browser. Mobile browsers have memory limits smaller than the model size.</p>
<p>Please visit this page on a desktop computer.</p>
`;
const actionSection = document.querySelector('.loading-action-section');
if (actionSection) {
actionSection.parentNode.insertBefore(warningDiv, actionSection);
}
const btnText = document.getElementById('loading-btn-text');
if (btnText) {
btnText.textContent = 'Try Anyway';
}
}
function isSafariDesktop() {
const ua = navigator.userAgent;
// Safari has "Safari" in UA but Chrome/Edge also include it
// True Safari doesn't have "Chrome" or "Chromium" in UA
const isSafari = /Safari/.test(ua) && !/Chrome|Chromium/.test(ua);
// Exclude iOS (iPhone, iPad, iPod)
const isIOS = /iPhone|iPad|iPod/.test(ua);
return isSafari && !isIOS;
}
function showSafariWarning() {
if (!isSafariDesktop()) return;
// Add Safari class to body for CSS targeting
document.body.classList.add('is-safari');
const warningDiv = document.createElement('div');
warningDiv.className = 'safari-warning';
warningDiv.innerHTML = `
<div class="safari-warning-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
Safari requires WebGPU to be explicitly enabled
</div>
<p>WebGPU must be manually enabled in Safari. Go to <strong>Safari → Settings → Feature Flags </strong> and enable <strong>WebGPU</strong>.</p>
<p><a href="https://webkit.org/blog/14879/webgpu-now-available-for-testing-in-safari-technology-preview/" target="_blank" rel="noopener noreferrer">Learn more about WebGPU in Safari</a></p>
`;
const actionSection = document.querySelector('.loading-action-section');
if (actionSection) {
actionSection.parentNode.insertBefore(warningDiv, actionSection);
}
}
// Initialize loading screen on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
initLoadingScreen();
showMobileWarning();
showSafariWarning();
});
} else {
initLoadingScreen();
showMobileWarning();
showSafariWarning();
}
// ============================================
// GENERATION CONFIG
// ============================================
const generationConfig = {
max_new_tokens: 128
};
function getModeConfig() {
return {
max_new_tokens: generationConfig.max_new_tokens
};
}
// Make getModeConfig available globally for main.js
window.getModeConfig = getModeConfig;
// ============================================
// LIVE CAPTION MODE
// ============================================
let liveCaptionStream = null;
let isCapturing = false;
let captureLoopRunning = false;
async function startLiveCaption() {
try {
const video = document.getElementById('live-caption-video');
const statusIndicator = document.getElementById('live-status-indicator');
const statusText = document.getElementById('live-status-text');
const startBtn = document.getElementById('start-live-caption-btn');
// Get webcam stream
liveCaptionStream = await navigator.mediaDevices.getUserMedia({
video: { width: 1024, height: 1024 }
});
video.srcObject = liveCaptionStream;
// Wait for video to actually start playing before capturing
await new Promise((resolve) => {
video.onloadeddata = () => {
video.play();
resolve();
};
// If already loaded, resolve immediately
if (video.readyState >= 2) {
video.play();
resolve();
}
});
// Wait for camera to warm up (avoid black first frame)
await new Promise(resolve => setTimeout(resolve, 500));
// Update UI
isCapturing = true;
statusIndicator.classList.add('active');
statusText.textContent = 'Capturing';
startBtn.textContent = 'Stop';
startBtn.classList.add('stop');
// Hide the initial placeholder text
const latestCaption = document.getElementById('latest-caption');
if (latestCaption) {
latestCaption.style.display = 'none';
}
// Start capture loop (waits for each generation to complete)
captureLoop();
} catch (error) {
console.error('Error starting live caption:', error);
alert('Could not access webcam. Please check permissions.');
}
}
function stopLiveCaption() {
const video = document.getElementById('live-caption-video');
const statusIndicator = document.getElementById('live-status-indicator');
const statusText = document.getElementById('live-status-text');
const startBtn = document.getElementById('start-live-caption-btn');
// Setting isCapturing to false stops the capture loop
isCapturing = false;
if (liveCaptionStream) {
liveCaptionStream.getTracks().forEach(track => track.stop());
liveCaptionStream = null;
}
if (video) {
video.srcObject = null;
}
if (statusIndicator) statusIndicator.classList.remove('active');
if (statusText) statusText.textContent = 'Idle';
if (startBtn) {
startBtn.textContent = 'Start';
startBtn.classList.remove('stop');
}
}
// Expose globally so main.js can stop capture before loading new model
window.stopLiveCaption = stopLiveCaption;
/**
* Async capture loop - waits for each generation to complete before starting next
*/
async function captureLoop() {
if (!isCapturing || captureLoopRunning) return;
captureLoopRunning = true;
while (isCapturing) {
await captureLiveCaptionFrame();
}
captureLoopRunning = false;
}
async function captureLiveCaptionFrame() {
if (!isCapturing) return;
const video = document.getElementById('live-caption-video');
const statusText = document.getElementById('live-status-text');
// Get resolution from dropdown
const resolutionSelect = document.getElementById('live-caption-resolution-select');
const resolution = parseInt(resolutionSelect?.value || '384', 10);
// Create canvas and capture frame
const canvas = document.createElement('canvas');
canvas.width = resolution;
canvas.height = resolution;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, resolution, resolution);
// Use DataURL - browser's native JPEG encoding is faster than JS ImageData handling
const dataURL = canvas.toDataURL('image/jpeg', 0.8);
try {
const config = getModeConfig();
// Wait for webgpuInit to be available
if (!window.webgpuInit) {
await new Promise((resolve, reject) => {
if (window.webgpuInit) {
resolve();
} else {
const timeout = setTimeout(() => {
reject(new Error('WebGPU system initialization timeout'));
}, 10000);
window.addEventListener('webgpu-ready', () => {
clearTimeout(timeout);
resolve();
}, { once: true });
}
});
}
if (!window.webgpuInit.isModelLoaded()) {
if (statusText) statusText.textContent = 'Model not loaded';
return;
}
// Build message
const messages = [{
role: 'user',
content: [
{ type: 'image', value: dataURL },
{ type: 'text', value: 'Describe what you see in one sentence.' }
]
}];
const response = await window.webgpuInit.generate(messages, {
maxNewTokens: config.max_new_tokens
});
updateLiveCaption(response);
} catch (error) {
console.error('Error generating caption:', error);
if (statusText) statusText.textContent = 'Error';
}
}
function updateLiveCaption(caption) {
// Skip empty or whitespace-only captions (happens when model is busy)
if (!caption || !caption.trim()) {
return;
}
const captionHistory = document.getElementById('caption-history');
// Remove 'latest' class from all existing items
const existingItems = captionHistory.querySelectorAll('.caption-history-item');
existingItems.forEach(item => item.classList.remove('latest'));
// Add to history at the top (most recent first)
const timestamp = new Date().toLocaleTimeString();
const historyItem = document.createElement('div');
historyItem.className = 'caption-history-item latest';
historyItem.innerHTML = `
<span class="caption-timestamp">${timestamp}</span>
<span class="caption-text">${caption.trim()}</span>
`;
captionHistory.prepend(historyItem);
// Keep only the last 7 items, remove oldest from bottom
while (captionHistory.children.length > 7) {
captionHistory.removeChild(captionHistory.lastChild);
}
// Apply fading effect: newest (first) is fully visible, older ones fade
const items = captionHistory.children;
for (let i = 0; i < items.length; i++) {
// First item is 1.0, then fade to 0.1 for older items
const opacity = Math.max(0.1, 1.0 - (i * 0.2));
items[i].style.opacity = opacity;
}
}
// Model state storage - don't restore from localStorage since model needs to be reloaded each session
let CURRENT_MODEL = '';
function formatModelName(modelId) {
// Convert "LFM2.5-VL-1.6B-merge-linear-Q4-FP16" to "LFM2.5-VL-1.6B Q4/FP16"
if (!modelId || modelId === 'Loading...') return modelId;
// Remove "merge-linear-" or similar internal identifiers
let clean = modelId.replace(/-merge-linear/g, '').replace(/-91\d+/g, '');
// Convert trailing "Q4-FP16" to "Q4/FP16"
clean = clean.replace(/-(Q4|FP16)-(Q4|FP16)$/, ' $1/$2');
return clean;
}
function updateModelStatus(modelId = null) {
const statusEl = document.getElementById('model-status');
if (statusEl) {
if (modelId) {
CURRENT_MODEL = modelId;
localStorage.setItem('CURRENT_MODEL', modelId);
statusEl.textContent = formatModelName(modelId);
statusEl.style.color = 'var(--text-primary)';
} else {
CURRENT_MODEL = '';
localStorage.removeItem('CURRENT_MODEL');
statusEl.textContent = 'No model loaded';
statusEl.style.color = 'var(--text-secondary)';
}
}
}
async function loadSelectedModel(modelId) {
const modelSelect = document.getElementById('model-select');
const reloadBtn = document.getElementById('reload-model-btn');
// Show loading state
if (modelSelect) {
modelSelect.disabled = true;
}
if (reloadBtn) {
reloadBtn.disabled = true;
reloadBtn.style.opacity = '0.6';
reloadBtn.style.cursor = 'not-allowed';
}
// Update status to loading
updateModelStatus('Loading...');
try {
// Wait for webgpuInit to be available
if (!window.webgpuInit) {
await new Promise((resolve, reject) => {
if (window.webgpuInit) {
resolve();
} else {
const timeout = setTimeout(() => {
reject(new Error('WebGPU system initialization timeout. Please refresh the page.'));
}, 10000);
window.addEventListener('webgpu-ready', () => {
clearTimeout(timeout);
resolve();
}, { once: true });
}
});
}
await window.webgpuInit.handleLoadModel();
// Store current model (status already updated by handleLoadModel)
CURRENT_MODEL = modelId;
localStorage.setItem('CURRENT_MODEL', CURRENT_MODEL);
// Enable buttons when model loads
if (window.webgpuInit && window.webgpuInit.updateButtonStates) {
window.webgpuInit.updateButtonStates(true);
}
} catch (error) {
updateModelStatus(null);
alert(`Error loading model: ${error.message}`);
console.error('Error loading model:', error);
} finally {
// Restore UI state
if (modelSelect) {
modelSelect.disabled = false;
}
if (reloadBtn) {
reloadBtn.disabled = false;
reloadBtn.style.opacity = '1';
reloadBtn.style.cursor = 'pointer';
}
}
}
// Inference via WebGPU
async function generate(messages, options = {}) {
const config = getModeConfig();
// Wait for webgpuInit to be available
if (!window.webgpuInit) {
// Wait for webgpu-ready event
await new Promise((resolve, reject) => {
if (window.webgpuInit) {
resolve();
} else {
const timeout = setTimeout(() => {
reject(new Error('WebGPU system initialization timeout. Please refresh the page.'));
}, 10000);
window.addEventListener('webgpu-ready', () => {
clearTimeout(timeout);
resolve();
}, { once: true });
}
});
}
if (!window.webgpuInit.isModelLoaded()) {
throw new Error('Model not loaded. Please load a model first.');
}
try {
// Generate using WebGPU with streaming support
const response = await window.webgpuInit.generate(messages, {
maxNewTokens: config.max_new_tokens,
onToken: options.onToken || ((token) => {
// Default: do nothing if no callback provided
return false;
})
});
return response;
} catch (error) {
console.error('Error calling WebGPU inference:', error);
throw error;
}
}
// Initialize
function init() {
setupModeEventListeners();
// Clear any URL hash from old bookmarks/links
if (window.location.hash) {
history.replaceState(null, '', window.location.pathname);
}
}
function setupModeEventListeners() {
const reloadModelBtn = document.getElementById('reload-model-btn');
if (reloadModelBtn) {
reloadModelBtn.addEventListener('click', () => {
const modelSelect = document.getElementById('model-select');
const selectedModelId = modelSelect?.value;
if (!selectedModelId) {
alert('Please select a model first.');
return;
}
loadSelectedModel(selectedModelId);
});
}
// Live Caption controls
const liveCaptionBtn = document.getElementById('start-live-caption-btn');
if (liveCaptionBtn) {
liveCaptionBtn.addEventListener('click', () => {
if (isCapturing) {
stopLiveCaption();
} else {
startLiveCaption();
}
});
}
// Model selector dropdown
const modelSelect = document.getElementById('model-select');
if (modelSelect) {
// Restore last selected model if saved
const savedModelId = localStorage.getItem('SELECTED_MODEL_ID');
if (savedModelId && modelSelect.querySelector(`option[value="${savedModelId}"]`)) {
modelSelect.value = savedModelId;
}
// Initialize model status display
if (CURRENT_MODEL) {
updateModelStatus(CURRENT_MODEL);
} else {
updateModelStatus(null);
}
// Save selection on change
modelSelect.addEventListener('change', (e) => {
localStorage.setItem('SELECTED_MODEL_ID', e.target.value);
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
</script>
<!-- Load main.js module -->
<script type="module" src="./main.js"></script>
</body>
</html>