Spaces:
Running
Running
upload demo files
#1
by
Xenova HF Staff - opened
This view is limited to 50 files because it contains too many changes.
See the raw diff here.
- .gitattributes +10 -0
- README.md +7 -1
- dist/assets/index-BD4hRAJu.js +0 -0
- dist/assets/index-BOaEXwaP.css +1 -0
- dist/fonts/JetBrains/JetBrainsMono-Italic-VariableFont_wght.ttf +3 -0
- dist/fonts/JetBrains/JetBrainsMono-VariableFont_wght.ttf +3 -0
- dist/fonts/Söhne/Söhne-Buch.otf +3 -0
- dist/fonts/Söhne/Söhne-Kräftig.otf +3 -0
- dist/fonts/Söhne/Söhne-Leicht.otf +3 -0
- dist/index.html +17 -0
- dist/liquid.svg +11 -0
- dist/logo-dark.webp +0 -0
- dist/logo-light.webp +0 -0
- eslint.config.js +23 -0
- index.html +14 -17
- package.json +38 -0
- public/fonts/JetBrains/JetBrainsMono-Italic-VariableFont_wght.ttf +3 -0
- public/fonts/JetBrains/JetBrainsMono-VariableFont_wght.ttf +3 -0
- public/fonts/Söhne/Söhne-Buch.otf +3 -0
- public/fonts/Söhne/Söhne-Kräftig.otf +3 -0
- public/fonts/Söhne/Söhne-Leicht.otf +3 -0
- public/liquid.svg +11 -0
- public/logo-dark.webp +0 -0
- public/logo-light.webp +0 -0
- src/App.tsx +326 -0
- src/components/BrandMark.tsx +11 -0
- src/components/CaptureScene.tsx +419 -0
- src/components/FluidBackdrop.tsx +151 -0
- src/components/HfIcon.tsx +39 -0
- src/context/VLMContext.ts +29 -0
- src/context/VLMProvider.tsx +247 -0
- src/index.css +735 -0
- src/main.tsx +10 -0
- src/react-fluid-distortion/Fluid.tsx +204 -0
- src/react-fluid-distortion/LICENSE +21 -0
- src/react-fluid-distortion/constant.ts +23 -0
- src/react-fluid-distortion/effect/Fluid.tsx +22 -0
- src/react-fluid-distortion/effect/FluidEffect.tsx +61 -0
- src/react-fluid-distortion/glsl.d.ts +24 -0
- src/react-fluid-distortion/glsl/advection.frag +16 -0
- src/react-fluid-distortion/glsl/base.vert +26 -0
- src/react-fluid-distortion/glsl/clear.frag +7 -0
- src/react-fluid-distortion/glsl/composite.frag +41 -0
- src/react-fluid-distortion/glsl/curl.frag +22 -0
- src/react-fluid-distortion/glsl/divergence.frag +41 -0
- src/react-fluid-distortion/glsl/gradientSubstract.frag +26 -0
- src/react-fluid-distortion/glsl/pressure.frag +28 -0
- src/react-fluid-distortion/glsl/splat.frag +19 -0
- src/react-fluid-distortion/glsl/vorticity.frag +36 -0
- src/react-fluid-distortion/hooks/useDoubleFBO.tsx +41 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,13 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
dist/fonts/JetBrains/JetBrainsMono-Italic-VariableFont_wght.ttf filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
dist/fonts/JetBrains/JetBrainsMono-VariableFont_wght.ttf filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
dist/fonts/Söhne/Söhne-Buch.otf filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
dist/fonts/Söhne/Söhne-Kräftig.otf filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
dist/fonts/Söhne/Söhne-Leicht.otf filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
public/fonts/JetBrains/JetBrainsMono-Italic-VariableFont_wght.ttf filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
public/fonts/JetBrains/JetBrainsMono-VariableFont_wght.ttf filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
public/fonts/Söhne/Söhne-Buch.otf filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
public/fonts/Söhne/Söhne-Kräftig.otf filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
public/fonts/Söhne/Söhne-Leicht.otf filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,10 +1,16 @@
|
|
| 1 |
---
|
| 2 |
title: LFM2 VL WebGPU
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: purple
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
| 1 |
---
|
| 2 |
title: LFM2 VL WebGPU
|
| 3 |
+
emoji: 📹
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: purple
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
short_description: Real-time video captioning in your browser
|
| 9 |
+
models:
|
| 10 |
+
- LiquidAI/LFM2-VL-450M
|
| 11 |
+
- onnx-community/LFM2-VL-450M-ONNX
|
| 12 |
+
thumbnail: https://cdn-uploads.huggingface.co/production/uploads/61b253b7ac5ecaae3d1efe0c/Obs_kK1de825UGpsBIuAI.png
|
| 13 |
+
app_file: dist/index.html
|
| 14 |
---
|
| 15 |
|
| 16 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
dist/assets/index-BD4hRAJu.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/index-BOaEXwaP.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@font-face{font-family:Sohne;font-style:normal;font-weight:400;src:url(/fonts/S%C3%B6hne/S%C3%B6hne-Buch.otf)format("opentype")}@font-face{font-family:Sohne;font-style:normal;font-weight:300;src:url(/fonts/S%C3%B6hne/S%C3%B6hne-Leicht.otf)format("opentype")}@font-face{font-family:Sohne;font-style:normal;font-weight:700;src:url(/fonts/S%C3%B6hne/S%C3%B6hne-Kr%C3%A4ftig.otf)format("opentype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:100 800;src:url(/fonts/JetBrains/JetBrainsMono-VariableFont_wght.ttf)format("truetype")}:root{--bg:#050814;--bg-soft:#080d19b8;--panel:#0a0f1cad;--panel-strong:#ffffff1f;--line:#ffffff1f;--line-strong:#ffffff38;--text:#f3f7ff;--text-soft:#f3f7ffb8;--text-muted:#f3f7ff8a;--accent:#9de0ff;--accent-strong:#d7f4ff;--shadow:0 28px 80px #02061159;--font-body:"Sohne", sans-serif;--font-mono:"JetBrains Mono", monospace}*{box-sizing:border-box}html,body,#root{min-height:100%}html{background:var(--bg)}body{background:var(--bg);color:var(--text);font-family:var(--font-body);font-synthesis:none;text-rendering:optimizelegibility;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;margin:0;overflow:hidden}button,input,textarea{color:inherit;font:inherit}button{cursor:pointer;border:0}img{max-width:100%;display:block}.hidden-file-input,.capture-canvas{display:none}.fluid-backdrop{background:#000;position:fixed;inset:0}.fluid-backdrop canvas{display:block}.fluid-backdrop__scene{opacity:0;width:100%;height:100%;transition:opacity .9s cubic-bezier(.16,1,.3,1),transform .9s cubic-bezier(.16,1,.3,1);transform:scale(1.02)}.fluid-backdrop__scene.is-ready{opacity:1;transform:scale(1)}.fluid-backdrop__veil{pointer-events:none;background:0 0;transition:background-color .32s;position:absolute;inset:0}.fluid-backdrop__veil.is-subdued{background:#03071042}.landing-scene,.scene-shell,.capture-scene{z-index:1;position:relative}.landing-scene{text-align:left;background:0 0;width:100%;min-height:100vh;padding:28px}.landing-inner{flex-direction:column;justify-content:space-between;gap:32px;width:min(1120px,100%);min-height:calc(100vh - 56px);margin:0 auto;display:flex}.brand-mark{border:1px solid var(--line);width:fit-content;box-shadow:var(--shadow);-webkit-backdrop-filter:blur(18px);background:#070b168a;border-radius:999px;align-items:center;gap:14px;padding:11px 17px 11px 11px;display:inline-flex}.brand-logo{object-fit:contain;width:36px;height:36px}.brand-copy{flex-direction:column;gap:3px;display:flex}.brand-copy span{color:var(--text-muted);font-family:var(--font-mono);letter-spacing:.18em;text-transform:uppercase;font-size:.7rem}.brand-copy strong{letter-spacing:.01em;font-size:.95rem;font-weight:700}.hero-copy{max-width:760px}.eyebrow,.dock-label{color:var(--accent);font-family:var(--font-mono);letter-spacing:.22em;text-transform:uppercase;font-size:.73rem;display:inline-block}.hero-copy h1,.loading-card h2,.source-card h2{letter-spacing:-.06em;margin:18px 0 0;font-size:clamp(3.4rem,9vw,7.8rem);font-weight:700;line-height:.95}.hero-copy p,.source-card p{max-width:620px;color:var(--text-soft);margin:20px 0 0;font-size:clamp(1.05rem,2.6vw,1.42rem);line-height:1.5}.hero-inline-icon{vertical-align:text-bottom;width:1.6rem;height:1.6rem;margin:0 .25rem 1px .35rem;display:inline-block}.hero-inline-wordmark{display:inline-block}.begin-prompt{border:1px solid var(--line-strong);width:fit-content;color:var(--accent-strong);letter-spacing:.02em;-webkit-backdrop-filter:blur(16px);background:#ffffff14;border-radius:999px;align-items:center;gap:12px;margin:0 auto;padding:14px 20px;font-size:.96rem;display:inline-flex}.scene-shell{flex-direction:column;justify-content:center;width:100%;min-height:100vh;padding:28px;display:flex}.scene-shell--centered{align-items:center;gap:28px}.scene-header{position:absolute;top:28px;left:28px}.loading-card,.source-card,.prompt-dock,.floating-alert{border:1px solid var(--line);background:var(--panel);box-shadow:var(--shadow);-webkit-backdrop-filter:blur(24px)}.loading-card{border-radius:32px;width:min(520px,100%);padding:30px}.loading-card h2{margin-top:16px;font-size:clamp(2.3rem,6vw,4.3rem)}.loading-card p{color:var(--text-soft);font-family:var(--font-mono);margin:16px 0 0;font-size:.9rem}.progress-track{background:#ffffff14;border-radius:999px;width:100%;height:12px;margin-top:24px;overflow:hidden}.progress-fill{border-radius:inherit;background:linear-gradient(90deg,#80deffcc,#f5fbfffa);height:100%;box-shadow:0 0 40px #9ce4ff66}.source-card{border-radius:36px;width:min(860px,100%);margin:0 auto;padding:32px}.source-card h2{margin-top:16px;font-size:clamp(2.2rem,5vw,4.8rem)}.source-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin-top:30px;display:grid}.source-option,.ghost-button,.primary-button,.prompt-chip{transition:transform .18s,border-color .18s,background-color .18s,opacity .18s}.source-option{border:1px solid var(--line);text-align:left;background:#ffffff0d;border-radius:28px;flex-direction:column;justify-content:space-between;gap:8px;padding:26px;display:flex}.source-option strong{letter-spacing:-.02em;font-size:1.45rem;font-weight:700}.source-option__header{align-items:center;gap:14px;display:inline-flex}.source-option__icon,.button-icon{color:var(--accent)}.source-option span{color:var(--text-soft);font-size:1.06rem;line-height:1.5}.source-option:hover,.ghost-button:hover,.primary-button:hover,.prompt-chip:hover{transform:translateY(-2px)}.capture-scene{background:#03050a;min-height:100vh;overflow:hidden}.capture-video,.capture-scrim{position:absolute;inset:0}.capture-video{object-fit:cover;width:100%;height:100%}.capture-scrim{background:linear-gradient(#03050a26,#03050a47 36%,#03050aad),radial-gradient(circle at 0 0,#92dcff33,#0000 34%),radial-gradient(circle at 100% 100%,#ffffff17,#0000 24%)}.capture-toolbar{z-index:2;justify-content:space-between;gap:16px;padding:24px;display:flex;position:relative}.capture-toolbar__left{flex-wrap:wrap;align-items:center;gap:14px;display:flex}.status-pill,.ghost-button,.primary-button{border:1px solid var(--line);-webkit-backdrop-filter:blur(16px);background:#080d1870;border-radius:999px;align-items:center;gap:10px;padding:12px 16px;display:inline-flex}.button-icon{flex-shrink:0}.status-pill{color:var(--text-soft);font-family:var(--font-mono);letter-spacing:.08em;text-transform:uppercase;font-size:.78rem}.status-dot{background:#ffffff4d;border-radius:999px;width:8px;height:8px}.status-dot.is-live{background:#9ce5ff;box-shadow:0 0 16px #9ce5ffb3}.toolbar-actions{flex-wrap:wrap;justify-content:flex-end;gap:10px;display:flex}.ghost-button{color:var(--text)}.ghost-button--small{padding:8px 12px}.primary-button{background:#ffffff1f;width:fit-content;margin-top:18px}.floating-alert{z-index:3;border-radius:20px;align-items:center;gap:14px;max-width:min(620px,100vw - 48px);padding:14px 16px;display:inline-flex;position:absolute;top:96px;left:24px}.floating-alert--secondary{top:156px}.error-banner{color:#ffd9d9;background:#ff787814;border:1px solid #ffa0a04d;border-radius:18px;margin-top:18px;padding:14px 16px;line-height:1.5}.prompt-dock,.capture-side-rail{z-index:2;position:absolute;bottom:24px}.prompt-dock{border-radius:30px;width:min(520px,100vw - 48px);padding:18px;left:24px}.prompt-chip-row{flex-wrap:wrap;gap:10px;margin:14px 0 16px;display:flex}.prompt-chip{border:1px solid var(--line);color:var(--text-soft);background:#ffffff0d;border-radius:999px;padding:10px 14px}.prompt-chip.is-active{color:var(--accent-strong);background:#9de0ff24;border-color:#9de0ff6b}.prompt-input{border:1px solid var(--line);width:100%;min-height:78px;color:var(--text);resize:none;background:#ffffff0a;border-radius:20px;padding:14px 16px;line-height:1.5}.prompt-input::placeholder{color:var(--text-muted)}.prompt-input:focus{border-color:#9de0ff70;outline:1px solid #9de0ff70}.capture-side-rail{flex-direction:column;gap:14px;width:min(400px,100vw - 48px);display:flex;right:24px}.capture-actions{z-index:4;pointer-events:auto;flex-wrap:wrap;justify-content:flex-end;gap:10px;display:flex;position:fixed;top:24px;right:24px}.caption-dock{flex-direction:column;align-items:stretch;gap:12px;width:100%;display:flex}.caption-bubble{color:#07101b;border-radius:30px 30px 12px;width:100%;padding:16px 18px;line-height:1.48;box-shadow:0 20px 48px #0307113d}.caption-bubble--history{background:#ffffffc7}.caption-bubble--active{background:#fffffffa;min-height:74px}.caption-meta{color:#07101b8a;font-family:var(--font-mono);letter-spacing:.18em;text-transform:uppercase;margin-bottom:8px;font-size:.72rem}.caption-placeholder{color:#07101b80}@media (width<=900px){body{overflow:auto}.landing-scene,.scene-shell{padding:22px}.landing-inner{min-height:calc(100vh - 44px)}.source-grid{grid-template-columns:1fr}.capture-toolbar{flex-direction:column;align-items:flex-start}.prompt-dock,.capture-side-rail{width:auto;margin:0 22px 22px;position:relative;bottom:auto;left:auto;right:auto}.capture-actions{justify-content:flex-start;top:22px;left:22px;right:22px}.capture-scene{flex-direction:column;justify-content:flex-end;min-height:100dvh;display:flex}.capture-scrim{background:linear-gradient(#03050a33,#03050a6b 42%,#03050ad6),radial-gradient(circle at 0 0,#92dcff2e,#0000 30%)}}@media (width<=640px){.hero-copy h1,.loading-card h2,.source-card h2{letter-spacing:-.05em}.floating-alert{flex-direction:column;align-items:flex-start;inset:auto 22px 22px}.floating-alert--secondary{bottom:96px}}
|
dist/fonts/JetBrains/JetBrainsMono-Italic-VariableFont_wght.ttf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d2a1563e89aa3c3816abfbca03e295abcdca11d9cbd689a7754cc1c5f454d18f
|
| 3 |
+
size 191988
|
dist/fonts/JetBrains/JetBrainsMono-VariableFont_wght.ttf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b6490e1a902e56fc84050bee9aad91509e6f45aa00f96f882dab53c9abaf83eb
|
| 3 |
+
size 187860
|
dist/fonts/Söhne/Söhne-Buch.otf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d3e050e7df5a5695e1ba1691633f2a8767ea9c6ac747fccf7b23a38e4ca02cc2
|
| 3 |
+
size 191552
|
dist/fonts/Söhne/Söhne-Kräftig.otf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7f17003124700a22684c3f83ac8252793f1e6e902842e385d4bd4220f94a79cb
|
| 3 |
+
size 245976
|
dist/fonts/Söhne/Söhne-Leicht.otf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:366970f59ef3332afd6d0a2a5bc84e71c002c2a351a93a8a66f315e5892be028
|
| 3 |
+
size 191884
|
dist/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link
|
| 6 |
+
rel="icon"
|
| 7 |
+
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💧</text></svg>"
|
| 8 |
+
/>
|
| 9 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 10 |
+
<title>lfm2-vl-webgpu</title>
|
| 11 |
+
<script type="module" crossorigin src="/assets/index-BD4hRAJu.js"></script>
|
| 12 |
+
<link rel="stylesheet" crossorigin href="/assets/index-BOaEXwaP.css">
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<div id="root"></div>
|
| 16 |
+
</body>
|
| 17 |
+
</html>
|
dist/liquid.svg
ADDED
|
|
dist/logo-dark.webp
ADDED
|
dist/logo-light.webp
ADDED
|
eslint.config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from "@eslint/js";
|
| 2 |
+
import globals from "globals";
|
| 3 |
+
import reactHooks from "eslint-plugin-react-hooks";
|
| 4 |
+
import reactRefresh from "eslint-plugin-react-refresh";
|
| 5 |
+
import tseslint from "typescript-eslint";
|
| 6 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 7 |
+
|
| 8 |
+
export default defineConfig([
|
| 9 |
+
globalIgnores(["dist", "src/react-fluid-distortion/**"]),
|
| 10 |
+
{
|
| 11 |
+
files: ["**/*.{ts,tsx}"],
|
| 12 |
+
extends: [
|
| 13 |
+
js.configs.recommended,
|
| 14 |
+
tseslint.configs.recommended,
|
| 15 |
+
reactHooks.configs.flat.recommended,
|
| 16 |
+
reactRefresh.configs.vite,
|
| 17 |
+
],
|
| 18 |
+
languageOptions: {
|
| 19 |
+
ecmaVersion: 2020,
|
| 20 |
+
globals: globals.browser,
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
]);
|
index.html
CHANGED
|
@@ -1,19 +1,16 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
</p>
|
| 17 |
-
</div>
|
| 18 |
-
</body>
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link
|
| 6 |
+
rel="icon"
|
| 7 |
+
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💧</text></svg>"
|
| 8 |
+
/>
|
| 9 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 10 |
+
<title>lfm2-vl-webgpu</title>
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<div id="root"></div>
|
| 14 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 15 |
+
</body>
|
|
|
|
|
|
|
|
|
|
| 16 |
</html>
|
package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "lfm2-vl-webgpu",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@huggingface/transformers": "^4.0.0-next.7",
|
| 14 |
+
"@react-three/drei": "^10.7.7",
|
| 15 |
+
"@react-three/postprocessing": "^3.0.4",
|
| 16 |
+
"lucide-react": "^0.577.0",
|
| 17 |
+
"postprocessing": "^6.38.3",
|
| 18 |
+
"react": "^19.2.0",
|
| 19 |
+
"react-dom": "^19.2.0"
|
| 20 |
+
},
|
| 21 |
+
"devDependencies": {
|
| 22 |
+
"@eslint/js": "^9.39.1",
|
| 23 |
+
"@types/node": "^24.10.1",
|
| 24 |
+
"@types/react": "^19.2.7",
|
| 25 |
+
"@types/react-dom": "^19.2.3",
|
| 26 |
+
"@vitejs/plugin-react": "^5.1.1",
|
| 27 |
+
"eslint": "^9.39.1",
|
| 28 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 29 |
+
"eslint-plugin-react-refresh": "^0.4.24",
|
| 30 |
+
"globals": "^16.5.0",
|
| 31 |
+
"typescript": "~5.9.3",
|
| 32 |
+
"typescript-eslint": "^8.48.0",
|
| 33 |
+
"vite": "^8.0.0-beta.13"
|
| 34 |
+
},
|
| 35 |
+
"overrides": {
|
| 36 |
+
"vite": "^8.0.0-beta.13"
|
| 37 |
+
}
|
| 38 |
+
}
|
public/fonts/JetBrains/JetBrainsMono-Italic-VariableFont_wght.ttf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d2a1563e89aa3c3816abfbca03e295abcdca11d9cbd689a7754cc1c5f454d18f
|
| 3 |
+
size 191988
|
public/fonts/JetBrains/JetBrainsMono-VariableFont_wght.ttf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b6490e1a902e56fc84050bee9aad91509e6f45aa00f96f882dab53c9abaf83eb
|
| 3 |
+
size 187860
|
public/fonts/Söhne/Söhne-Buch.otf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d3e050e7df5a5695e1ba1691633f2a8767ea9c6ac747fccf7b23a38e4ca02cc2
|
| 3 |
+
size 191552
|
public/fonts/Söhne/Söhne-Kräftig.otf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7f17003124700a22684c3f83ac8252793f1e6e902842e385d4bd4220f94a79cb
|
| 3 |
+
size 245976
|
public/fonts/Söhne/Söhne-Leicht.otf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:366970f59ef3332afd6d0a2a5bc84e71c002c2a351a93a8a66f315e5892be028
|
| 3 |
+
size 191884
|
public/liquid.svg
ADDED
|
|
public/logo-dark.webp
ADDED
|
public/logo-light.webp
ADDED
|
src/App.tsx
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { startTransition, useEffect, useRef, useState } from "react";
|
| 2 |
+
import { Camera, Film } from "lucide-react";
|
| 3 |
+
import { BrandMark } from "./components/BrandMark";
|
| 4 |
+
import { CaptureScene, type CaptureSource } from "./components/CaptureScene";
|
| 5 |
+
import { FluidBackdrop } from "./components/FluidBackdrop";
|
| 6 |
+
import { HfIcon } from "./components/HfIcon";
|
| 7 |
+
import { VLMProvider } from "./context/VLMProvider";
|
| 8 |
+
import { useVLM } from "./context/VLMContext";
|
| 9 |
+
|
| 10 |
+
const PROMPT_PRESETS = [
|
| 11 |
+
{
|
| 12 |
+
display: "Describe the scene",
|
| 13 |
+
prompt: "Describe the scene in one sentence.",
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
display: "What color shirt am I wearing?",
|
| 17 |
+
prompt: "What color shirt am I wearing?",
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
display: "What am I holding?",
|
| 21 |
+
prompt: "What am I holding?",
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
display: "How old do I look?",
|
| 25 |
+
prompt: "How old do I look?",
|
| 26 |
+
},
|
| 27 |
+
] as const;
|
| 28 |
+
|
| 29 |
+
type Scene = "landing" | "loading" | "source" | "capture";
|
| 30 |
+
|
| 31 |
+
function disposeSource(source: CaptureSource | null) {
|
| 32 |
+
if (!source) {
|
| 33 |
+
return;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if (source.kind === "webcam") {
|
| 37 |
+
source.stream.getTracks().forEach((track) => track.stop());
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
URL.revokeObjectURL(source.url);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function getErrorMessage(error: unknown) {
|
| 45 |
+
if (error instanceof Error) {
|
| 46 |
+
return error.message;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return "Something went wrong.";
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function AppContent() {
|
| 53 |
+
const [scene, setScene] = useState<Scene>("landing");
|
| 54 |
+
const [source, setSource] = useState<CaptureSource | null>(null);
|
| 55 |
+
const [prompt, setPrompt] = useState<string>(PROMPT_PRESETS[0].prompt);
|
| 56 |
+
const [mediaError, setMediaError] = useState<string | null>(null);
|
| 57 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 58 |
+
const sourceRef = useRef<CaptureSource | null>(null);
|
| 59 |
+
|
| 60 |
+
const { error, loadModel, message, progress, status } = useVLM();
|
| 61 |
+
|
| 62 |
+
useEffect(() => {
|
| 63 |
+
sourceRef.current = source;
|
| 64 |
+
}, [source]);
|
| 65 |
+
|
| 66 |
+
useEffect(() => {
|
| 67 |
+
return () => {
|
| 68 |
+
disposeSource(sourceRef.current);
|
| 69 |
+
};
|
| 70 |
+
}, []);
|
| 71 |
+
|
| 72 |
+
useEffect(() => {
|
| 73 |
+
if (scene !== "loading" || status === "ready") {
|
| 74 |
+
return;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
let cancelled = false;
|
| 78 |
+
|
| 79 |
+
void loadModel()
|
| 80 |
+
.then(() => {
|
| 81 |
+
if (cancelled) {
|
| 82 |
+
return;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
startTransition(() => {
|
| 86 |
+
setScene("source");
|
| 87 |
+
});
|
| 88 |
+
})
|
| 89 |
+
.catch(() => undefined);
|
| 90 |
+
|
| 91 |
+
return () => {
|
| 92 |
+
cancelled = true;
|
| 93 |
+
};
|
| 94 |
+
}, [loadModel, scene, status]);
|
| 95 |
+
|
| 96 |
+
const beginExperience = () => {
|
| 97 |
+
startTransition(() => {
|
| 98 |
+
setScene("loading");
|
| 99 |
+
});
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
const replaceSource = (nextSource: CaptureSource) => {
|
| 103 |
+
disposeSource(source);
|
| 104 |
+
setMediaError(null);
|
| 105 |
+
setSource(nextSource);
|
| 106 |
+
|
| 107 |
+
startTransition(() => {
|
| 108 |
+
setScene("capture");
|
| 109 |
+
});
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const handleUseWebcam = async () => {
|
| 113 |
+
try {
|
| 114 |
+
if (!navigator.mediaDevices?.getUserMedia) {
|
| 115 |
+
throw new Error("Camera access is not available in this browser.");
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
const stream = await navigator.mediaDevices.getUserMedia({
|
| 119 |
+
audio: false,
|
| 120 |
+
video: {
|
| 121 |
+
facingMode: "user",
|
| 122 |
+
width: { ideal: 1280 },
|
| 123 |
+
height: { ideal: 720 },
|
| 124 |
+
},
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
replaceSource({
|
| 128 |
+
kind: "webcam",
|
| 129 |
+
label: "Live camera",
|
| 130 |
+
stream,
|
| 131 |
+
});
|
| 132 |
+
} catch (cameraError) {
|
| 133 |
+
setMediaError(getErrorMessage(cameraError));
|
| 134 |
+
}
|
| 135 |
+
};
|
| 136 |
+
|
| 137 |
+
const openVideoPicker = () => {
|
| 138 |
+
fileInputRef.current?.click();
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
const handleVideoSelection = (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 142 |
+
const file = event.target.files?.[0];
|
| 143 |
+
event.target.value = "";
|
| 144 |
+
|
| 145 |
+
if (!file) {
|
| 146 |
+
return;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
replaceSource({
|
| 150 |
+
kind: "file",
|
| 151 |
+
label: file.name,
|
| 152 |
+
url: URL.createObjectURL(file),
|
| 153 |
+
});
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
const exitCapture = () => {
|
| 157 |
+
disposeSource(source);
|
| 158 |
+
setSource(null);
|
| 159 |
+
setMediaError(null);
|
| 160 |
+
|
| 161 |
+
startTransition(() => {
|
| 162 |
+
setScene("source");
|
| 163 |
+
});
|
| 164 |
+
};
|
| 165 |
+
|
| 166 |
+
const showBackdrop = scene !== "capture";
|
| 167 |
+
|
| 168 |
+
return (
|
| 169 |
+
<>
|
| 170 |
+
{showBackdrop ? <FluidBackdrop subdued={scene === "loading"} /> : null}
|
| 171 |
+
|
| 172 |
+
<input
|
| 173 |
+
ref={fileInputRef}
|
| 174 |
+
accept="video/*"
|
| 175 |
+
className="hidden-file-input"
|
| 176 |
+
onChange={handleVideoSelection}
|
| 177 |
+
type="file"
|
| 178 |
+
/>
|
| 179 |
+
|
| 180 |
+
{scene === "landing" ? (
|
| 181 |
+
<button
|
| 182 |
+
className="landing-scene"
|
| 183 |
+
onClick={beginExperience}
|
| 184 |
+
type="button"
|
| 185 |
+
>
|
| 186 |
+
<div className="landing-inner">
|
| 187 |
+
<BrandMark />
|
| 188 |
+
|
| 189 |
+
<div className="hero-copy">
|
| 190 |
+
<h1>LFM2-VL WebGPU</h1>
|
| 191 |
+
<p>
|
| 192 |
+
Real-time video captioning in your browser,
|
| 193 |
+
<br />
|
| 194 |
+
powered by
|
| 195 |
+
<HfIcon className="hero-inline-icon" />
|
| 196 |
+
<span className="hero-inline-wordmark">Transformers.js</span>
|
| 197 |
+
</p>
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
<div className="begin-prompt">Click anywhere to begin</div>
|
| 201 |
+
</div>
|
| 202 |
+
</button>
|
| 203 |
+
) : null}
|
| 204 |
+
|
| 205 |
+
{scene === "loading" ? (
|
| 206 |
+
<main className="scene-shell scene-shell--centered">
|
| 207 |
+
<BrandMark />
|
| 208 |
+
|
| 209 |
+
<section className="loading-card">
|
| 210 |
+
<span className="eyebrow">Loading Model</span>
|
| 211 |
+
<h2>{message}</h2>
|
| 212 |
+
<div aria-hidden="true" className="progress-track">
|
| 213 |
+
<div
|
| 214 |
+
className="progress-fill"
|
| 215 |
+
style={{
|
| 216 |
+
width: `${Math.max(progress, status === "ready" ? 100 : 6)}%`,
|
| 217 |
+
}}
|
| 218 |
+
/>
|
| 219 |
+
</div>
|
| 220 |
+
<p>{Math.round(progress)}%</p>
|
| 221 |
+
|
| 222 |
+
{error ? (
|
| 223 |
+
<>
|
| 224 |
+
<div className="error-banner" role="alert">
|
| 225 |
+
{error}
|
| 226 |
+
</div>
|
| 227 |
+
<button
|
| 228 |
+
className="primary-button"
|
| 229 |
+
onClick={() => void loadModel()}
|
| 230 |
+
type="button"
|
| 231 |
+
>
|
| 232 |
+
Retry loading
|
| 233 |
+
</button>
|
| 234 |
+
</>
|
| 235 |
+
) : null}
|
| 236 |
+
</section>
|
| 237 |
+
</main>
|
| 238 |
+
) : null}
|
| 239 |
+
|
| 240 |
+
{scene === "source" ? (
|
| 241 |
+
<main className="scene-shell">
|
| 242 |
+
<div className="scene-header">
|
| 243 |
+
<BrandMark />
|
| 244 |
+
</div>
|
| 245 |
+
|
| 246 |
+
<section className="source-card">
|
| 247 |
+
<span className="eyebrow">Choose Input</span>
|
| 248 |
+
<h2>Caption a live camera or a local video file.</h2>
|
| 249 |
+
<p>
|
| 250 |
+
The model is ready. Pick a source and we'll start captioning
|
| 251 |
+
each frame as quickly as the browser can process it.
|
| 252 |
+
</p>
|
| 253 |
+
|
| 254 |
+
<div className="source-grid">
|
| 255 |
+
<button
|
| 256 |
+
className="source-option"
|
| 257 |
+
onClick={() => void handleUseWebcam()}
|
| 258 |
+
type="button"
|
| 259 |
+
>
|
| 260 |
+
<div className="source-option__header">
|
| 261 |
+
<Camera
|
| 262 |
+
className="source-option__icon"
|
| 263 |
+
size={28}
|
| 264 |
+
strokeWidth={1.9}
|
| 265 |
+
/>
|
| 266 |
+
<strong>Webcam</strong>
|
| 267 |
+
</div>
|
| 268 |
+
<span>
|
| 269 |
+
Start a live camera stream and caption it in real time.
|
| 270 |
+
</span>
|
| 271 |
+
</button>
|
| 272 |
+
|
| 273 |
+
<button
|
| 274 |
+
className="source-option"
|
| 275 |
+
onClick={openVideoPicker}
|
| 276 |
+
type="button"
|
| 277 |
+
>
|
| 278 |
+
<div className="source-option__header">
|
| 279 |
+
<Film
|
| 280 |
+
className="source-option__icon"
|
| 281 |
+
size={28}
|
| 282 |
+
strokeWidth={1.9}
|
| 283 |
+
/>
|
| 284 |
+
<strong>File</strong>
|
| 285 |
+
</div>
|
| 286 |
+
<span>
|
| 287 |
+
Upload a local clip and run the same caption loop against it.
|
| 288 |
+
</span>
|
| 289 |
+
</button>
|
| 290 |
+
</div>
|
| 291 |
+
|
| 292 |
+
{mediaError ? (
|
| 293 |
+
<div className="error-banner" role="alert">
|
| 294 |
+
{mediaError}
|
| 295 |
+
</div>
|
| 296 |
+
) : null}
|
| 297 |
+
</section>
|
| 298 |
+
</main>
|
| 299 |
+
) : null}
|
| 300 |
+
|
| 301 |
+
{scene === "capture" && source ? (
|
| 302 |
+
<CaptureScene
|
| 303 |
+
mediaError={mediaError}
|
| 304 |
+
onChooseVideo={openVideoPicker}
|
| 305 |
+
onChooseWebcam={handleUseWebcam}
|
| 306 |
+
onDismissMediaError={() => setMediaError(null)}
|
| 307 |
+
onExit={exitCapture}
|
| 308 |
+
onPromptChange={setPrompt}
|
| 309 |
+
prompt={prompt}
|
| 310 |
+
promptPresets={PROMPT_PRESETS}
|
| 311 |
+
source={source}
|
| 312 |
+
/>
|
| 313 |
+
) : null}
|
| 314 |
+
</>
|
| 315 |
+
);
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
function App() {
|
| 319 |
+
return (
|
| 320 |
+
<VLMProvider>
|
| 321 |
+
<AppContent />
|
| 322 |
+
</VLMProvider>
|
| 323 |
+
);
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
export default App;
|
src/components/BrandMark.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function BrandMark() {
|
| 2 |
+
return (
|
| 3 |
+
<div className="brand-mark">
|
| 4 |
+
<img alt="Liquid AI icon" className="brand-logo" src="/logo-light.webp" />
|
| 5 |
+
<div className="brand-copy">
|
| 6 |
+
<span>Liquid AI</span>
|
| 7 |
+
<strong>LFM2-VL WebGPU</strong>
|
| 8 |
+
</div>
|
| 9 |
+
</div>
|
| 10 |
+
);
|
| 11 |
+
}
|
src/components/CaptureScene.tsx
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
startTransition,
|
| 3 |
+
useDeferredValue,
|
| 4 |
+
useEffect,
|
| 5 |
+
useEffectEvent,
|
| 6 |
+
useRef,
|
| 7 |
+
useState,
|
| 8 |
+
} from "react";
|
| 9 |
+
import { ArrowLeft, Camera, Film, Pause, Play } from "lucide-react";
|
| 10 |
+
import { BrandMark } from "./BrandMark";
|
| 11 |
+
import { useVLM } from "../context/VLMContext";
|
| 12 |
+
|
| 13 |
+
export type CaptureSource =
|
| 14 |
+
| {
|
| 15 |
+
kind: "webcam";
|
| 16 |
+
label: string;
|
| 17 |
+
stream: MediaStream;
|
| 18 |
+
}
|
| 19 |
+
| {
|
| 20 |
+
kind: "file";
|
| 21 |
+
label: string;
|
| 22 |
+
url: string;
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
type CaptionEntry = {
|
| 26 |
+
id: string;
|
| 27 |
+
text: string;
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
type CaptureSceneProps = {
|
| 31 |
+
mediaError: string | null;
|
| 32 |
+
onChooseVideo: () => void;
|
| 33 |
+
onChooseWebcam: () => Promise<void>;
|
| 34 |
+
onDismissMediaError: () => void;
|
| 35 |
+
onExit: () => void;
|
| 36 |
+
onPromptChange: (prompt: string) => void;
|
| 37 |
+
prompt: string;
|
| 38 |
+
promptPresets: readonly {
|
| 39 |
+
display: string;
|
| 40 |
+
prompt: string;
|
| 41 |
+
}[];
|
| 42 |
+
source: CaptureSource;
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const CAPTION_LIMIT = 4;
|
| 46 |
+
|
| 47 |
+
function wait(milliseconds: number) {
|
| 48 |
+
return new Promise<void>((resolve) => {
|
| 49 |
+
window.setTimeout(resolve, milliseconds);
|
| 50 |
+
});
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function createCaptionId() {
|
| 54 |
+
return (
|
| 55 |
+
globalThis.crypto?.randomUUID?.() ??
|
| 56 |
+
`caption-${Date.now()}-${Math.random()}`
|
| 57 |
+
);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function normalizePrompt(text: string) {
|
| 61 |
+
return text.replace(/\s+/g, " ").trim();
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function getErrorMessage(error: unknown) {
|
| 65 |
+
if (error instanceof Error) {
|
| 66 |
+
return error.message;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return "Something went wrong while captioning the current frame.";
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export function CaptureScene({
|
| 73 |
+
mediaError,
|
| 74 |
+
onChooseVideo,
|
| 75 |
+
onChooseWebcam,
|
| 76 |
+
onDismissMediaError,
|
| 77 |
+
onExit,
|
| 78 |
+
onPromptChange,
|
| 79 |
+
prompt,
|
| 80 |
+
promptPresets,
|
| 81 |
+
source,
|
| 82 |
+
}: CaptureSceneProps) {
|
| 83 |
+
const { generateCaption } = useVLM();
|
| 84 |
+
const videoRef = useRef<HTMLVideoElement>(null);
|
| 85 |
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 86 |
+
const loopIdRef = useRef(0);
|
| 87 |
+
|
| 88 |
+
const [activeCaption, setActiveCaption] = useState("");
|
| 89 |
+
const [captionHistory, setCaptionHistory] = useState<CaptionEntry[]>([]);
|
| 90 |
+
const [isGenerating, setIsGenerating] = useState(false);
|
| 91 |
+
const [isPaused, setIsPaused] = useState(false);
|
| 92 |
+
const [runtimeError, setRuntimeError] = useState<string | null>(null);
|
| 93 |
+
const [videoReady, setVideoReady] = useState(false);
|
| 94 |
+
|
| 95 |
+
const deferredPrompt = useDeferredValue(
|
| 96 |
+
normalizePrompt(prompt) || promptPresets[0].prompt,
|
| 97 |
+
);
|
| 98 |
+
|
| 99 |
+
useEffect(() => {
|
| 100 |
+
const video = videoRef.current;
|
| 101 |
+
if (!video) {
|
| 102 |
+
return;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
setVideoReady(false);
|
| 106 |
+
setRuntimeError(null);
|
| 107 |
+
|
| 108 |
+
if (source.kind === "webcam") {
|
| 109 |
+
video.srcObject = source.stream;
|
| 110 |
+
video.removeAttribute("src");
|
| 111 |
+
void video.play().catch(() => undefined);
|
| 112 |
+
|
| 113 |
+
return () => {
|
| 114 |
+
video.pause();
|
| 115 |
+
video.srcObject = null;
|
| 116 |
+
};
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
video.srcObject = null;
|
| 120 |
+
video.src = source.url;
|
| 121 |
+
video.load();
|
| 122 |
+
void video.play().catch(() => undefined);
|
| 123 |
+
|
| 124 |
+
return () => {
|
| 125 |
+
video.pause();
|
| 126 |
+
video.removeAttribute("src");
|
| 127 |
+
video.load();
|
| 128 |
+
};
|
| 129 |
+
}, [source]);
|
| 130 |
+
|
| 131 |
+
useEffect(() => {
|
| 132 |
+
setCaptionHistory([]);
|
| 133 |
+
setActiveCaption("");
|
| 134 |
+
setIsGenerating(false);
|
| 135 |
+
setIsPaused(false);
|
| 136 |
+
}, [source]);
|
| 137 |
+
|
| 138 |
+
useEffect(() => {
|
| 139 |
+
if (!isPaused) {
|
| 140 |
+
return;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
setActiveCaption("");
|
| 144 |
+
setIsGenerating(false);
|
| 145 |
+
}, [isPaused]);
|
| 146 |
+
|
| 147 |
+
const handleCanPlay = () => {
|
| 148 |
+
setVideoReady(true);
|
| 149 |
+
void videoRef.current?.play().catch(() => undefined);
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
const captureFrame = useEffectEvent(() => {
|
| 153 |
+
const video = videoRef.current;
|
| 154 |
+
const canvas = canvasRef.current;
|
| 155 |
+
|
| 156 |
+
if (
|
| 157 |
+
!video ||
|
| 158 |
+
!canvas ||
|
| 159 |
+
!videoReady ||
|
| 160 |
+
video.paused ||
|
| 161 |
+
video.ended ||
|
| 162 |
+
video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA ||
|
| 163 |
+
video.videoWidth === 0 ||
|
| 164 |
+
video.videoHeight === 0
|
| 165 |
+
) {
|
| 166 |
+
return null;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
const maxDimension = 960;
|
| 170 |
+
const scale = Math.min(
|
| 171 |
+
1,
|
| 172 |
+
maxDimension / Math.max(video.videoWidth, video.videoHeight),
|
| 173 |
+
);
|
| 174 |
+
const width = Math.max(1, Math.round(video.videoWidth * scale));
|
| 175 |
+
const height = Math.max(1, Math.round(video.videoHeight * scale));
|
| 176 |
+
|
| 177 |
+
if (canvas.width !== width) {
|
| 178 |
+
canvas.width = width;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
if (canvas.height !== height) {
|
| 182 |
+
canvas.height = height;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
| 186 |
+
if (!context) {
|
| 187 |
+
return null;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
context.drawImage(video, 0, 0, width, height);
|
| 191 |
+
return context.getImageData(0, 0, width, height);
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
const runCaptionPass = useEffectEvent(async (loopId: number) => {
|
| 195 |
+
if (isPaused) {
|
| 196 |
+
await wait(120);
|
| 197 |
+
return;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
const frame = captureFrame();
|
| 201 |
+
if (!frame) {
|
| 202 |
+
await wait(120);
|
| 203 |
+
return;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
setRuntimeError(null);
|
| 207 |
+
setIsGenerating(true);
|
| 208 |
+
setActiveCaption("");
|
| 209 |
+
|
| 210 |
+
try {
|
| 211 |
+
const finalCaption = await generateCaption({
|
| 212 |
+
frame,
|
| 213 |
+
onStream: (text) => {
|
| 214 |
+
if (loopIdRef.current !== loopId) {
|
| 215 |
+
return;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
setActiveCaption(text);
|
| 219 |
+
},
|
| 220 |
+
prompt: deferredPrompt,
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
if (loopIdRef.current !== loopId) {
|
| 224 |
+
return;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
const normalizedCaption = normalizePrompt(finalCaption);
|
| 228 |
+
if (normalizedCaption.length === 0) {
|
| 229 |
+
return;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
startTransition(() => {
|
| 233 |
+
setCaptionHistory((current) => {
|
| 234 |
+
if (current[0]?.text === normalizedCaption) {
|
| 235 |
+
return current;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
return [
|
| 239 |
+
{ id: createCaptionId(), text: normalizedCaption },
|
| 240 |
+
...current,
|
| 241 |
+
].slice(0, CAPTION_LIMIT);
|
| 242 |
+
});
|
| 243 |
+
});
|
| 244 |
+
} catch (error) {
|
| 245 |
+
if (loopIdRef.current !== loopId) {
|
| 246 |
+
return;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
setRuntimeError(getErrorMessage(error));
|
| 250 |
+
await wait(240);
|
| 251 |
+
} finally {
|
| 252 |
+
if (loopIdRef.current === loopId) {
|
| 253 |
+
setActiveCaption("");
|
| 254 |
+
setIsGenerating(false);
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
});
|
| 258 |
+
|
| 259 |
+
useEffect(() => {
|
| 260 |
+
loopIdRef.current += 1;
|
| 261 |
+
const currentLoopId = loopIdRef.current;
|
| 262 |
+
let mounted = true;
|
| 263 |
+
|
| 264 |
+
const loop = async () => {
|
| 265 |
+
while (mounted && loopIdRef.current === currentLoopId) {
|
| 266 |
+
await runCaptionPass(currentLoopId);
|
| 267 |
+
await wait(72);
|
| 268 |
+
}
|
| 269 |
+
};
|
| 270 |
+
|
| 271 |
+
void loop();
|
| 272 |
+
|
| 273 |
+
return () => {
|
| 274 |
+
mounted = false;
|
| 275 |
+
loopIdRef.current += 1;
|
| 276 |
+
};
|
| 277 |
+
}, [source]);
|
| 278 |
+
|
| 279 |
+
const displayedHistory = [...captionHistory].reverse();
|
| 280 |
+
|
| 281 |
+
return (
|
| 282 |
+
<main className="capture-scene">
|
| 283 |
+
<video
|
| 284 |
+
ref={videoRef}
|
| 285 |
+
autoPlay
|
| 286 |
+
className="capture-video"
|
| 287 |
+
loop={source.kind === "file"}
|
| 288 |
+
muted
|
| 289 |
+
onCanPlay={handleCanPlay}
|
| 290 |
+
playsInline
|
| 291 |
+
/>
|
| 292 |
+
<canvas ref={canvasRef} className="capture-canvas" />
|
| 293 |
+
<div className="capture-scrim" />
|
| 294 |
+
|
| 295 |
+
<header className="capture-toolbar">
|
| 296 |
+
<div className="capture-toolbar__left">
|
| 297 |
+
<BrandMark />
|
| 298 |
+
<div className="status-pill">
|
| 299 |
+
<span className={`status-dot ${videoReady ? "is-live" : ""}`} />
|
| 300 |
+
{source.kind === "webcam" ? "Webcam" : source.label}
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
</header>
|
| 304 |
+
|
| 305 |
+
{mediaError ? (
|
| 306 |
+
<div className="floating-alert" role="alert">
|
| 307 |
+
<span>{mediaError}</span>
|
| 308 |
+
<button
|
| 309 |
+
className="ghost-button ghost-button--small"
|
| 310 |
+
onClick={onDismissMediaError}
|
| 311 |
+
type="button"
|
| 312 |
+
>
|
| 313 |
+
Dismiss
|
| 314 |
+
</button>
|
| 315 |
+
</div>
|
| 316 |
+
) : null}
|
| 317 |
+
|
| 318 |
+
{runtimeError ? (
|
| 319 |
+
<div className="floating-alert floating-alert--secondary" role="alert">
|
| 320 |
+
<span>{runtimeError}</span>
|
| 321 |
+
</div>
|
| 322 |
+
) : null}
|
| 323 |
+
|
| 324 |
+
<section className="prompt-dock">
|
| 325 |
+
<span className="dock-label">Prompt</span>
|
| 326 |
+
<div className="prompt-chip-row">
|
| 327 |
+
{promptPresets.map((preset) => (
|
| 328 |
+
<button
|
| 329 |
+
key={preset.display}
|
| 330 |
+
className={`prompt-chip ${prompt === preset.prompt ? "is-active" : ""}`}
|
| 331 |
+
onClick={() => onPromptChange(preset.prompt)}
|
| 332 |
+
type="button"
|
| 333 |
+
>
|
| 334 |
+
{preset.display}
|
| 335 |
+
</button>
|
| 336 |
+
))}
|
| 337 |
+
</div>
|
| 338 |
+
|
| 339 |
+
<textarea
|
| 340 |
+
className="prompt-input"
|
| 341 |
+
onChange={(event) => onPromptChange(event.target.value)}
|
| 342 |
+
placeholder="Ask the model anything about the current frame."
|
| 343 |
+
rows={3}
|
| 344 |
+
spellCheck={false}
|
| 345 |
+
value={prompt}
|
| 346 |
+
/>
|
| 347 |
+
</section>
|
| 348 |
+
|
| 349 |
+
<section className="capture-side-rail">
|
| 350 |
+
<div className="capture-actions">
|
| 351 |
+
<button
|
| 352 |
+
className="ghost-button"
|
| 353 |
+
onClick={() => setIsPaused((current) => !current)}
|
| 354 |
+
type="button"
|
| 355 |
+
>
|
| 356 |
+
{isPaused ? (
|
| 357 |
+
<Play className="button-icon" size={16} strokeWidth={1.8} />
|
| 358 |
+
) : (
|
| 359 |
+
<Pause className="button-icon" size={16} strokeWidth={1.8} />
|
| 360 |
+
)}
|
| 361 |
+
{isPaused ? "Resume" : "Pause"}
|
| 362 |
+
</button>
|
| 363 |
+
<button
|
| 364 |
+
className="ghost-button"
|
| 365 |
+
onClick={() => void onChooseWebcam()}
|
| 366 |
+
type="button"
|
| 367 |
+
>
|
| 368 |
+
<Camera className="button-icon" size={16} strokeWidth={1.8} />
|
| 369 |
+
Webcam
|
| 370 |
+
</button>
|
| 371 |
+
<button
|
| 372 |
+
className="ghost-button"
|
| 373 |
+
onClick={onChooseVideo}
|
| 374 |
+
type="button"
|
| 375 |
+
>
|
| 376 |
+
<Film className="button-icon" size={16} strokeWidth={1.8} />
|
| 377 |
+
Video file
|
| 378 |
+
</button>
|
| 379 |
+
<button className="ghost-button" onClick={onExit} type="button">
|
| 380 |
+
<ArrowLeft className="button-icon" size={16} strokeWidth={1.8} />
|
| 381 |
+
Back
|
| 382 |
+
</button>
|
| 383 |
+
</div>
|
| 384 |
+
|
| 385 |
+
<section className="caption-dock">
|
| 386 |
+
{displayedHistory.map((caption, index) => {
|
| 387 |
+
const depth = displayedHistory.length - index;
|
| 388 |
+
const opacity = Math.max(0.18, 1 - depth * 0.18);
|
| 389 |
+
const scale = 1 - depth * 0.04;
|
| 390 |
+
|
| 391 |
+
return (
|
| 392 |
+
<article
|
| 393 |
+
key={caption.id}
|
| 394 |
+
className="caption-bubble caption-bubble--history"
|
| 395 |
+
style={{
|
| 396 |
+
opacity,
|
| 397 |
+
transform: `translateY(${-depth * 8}px) scale(${scale})`,
|
| 398 |
+
}}
|
| 399 |
+
>
|
| 400 |
+
{caption.text}
|
| 401 |
+
</article>
|
| 402 |
+
);
|
| 403 |
+
})}
|
| 404 |
+
|
| 405 |
+
{activeCaption || isGenerating ? (
|
| 406 |
+
<article className="caption-bubble caption-bubble--active">
|
| 407 |
+
<div className="caption-meta">Live caption</div>
|
| 408 |
+
{activeCaption || (
|
| 409 |
+
<span className="caption-placeholder">
|
| 410 |
+
Scanning current frame...
|
| 411 |
+
</span>
|
| 412 |
+
)}
|
| 413 |
+
</article>
|
| 414 |
+
) : null}
|
| 415 |
+
</section>
|
| 416 |
+
</section>
|
| 417 |
+
</main>
|
| 418 |
+
);
|
| 419 |
+
}
|
src/components/FluidBackdrop.tsx
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Environment,
|
| 3 |
+
Float,
|
| 4 |
+
MeshTransmissionMaterial,
|
| 5 |
+
} from "@react-three/drei";
|
| 6 |
+
import { Canvas } from "@react-three/fiber";
|
| 7 |
+
import { EffectComposer } from "@react-three/postprocessing";
|
| 8 |
+
import { Suspense, useEffect, useState } from "react";
|
| 9 |
+
import { DEFAULT_CONFIG, Fluid } from "../react-fluid-distortion";
|
| 10 |
+
|
| 11 |
+
function BackdropGeometry() {
|
| 12 |
+
return (
|
| 13 |
+
<>
|
| 14 |
+
<ambientLight intensity={1.4} />
|
| 15 |
+
<directionalLight color="#ddf6ff" intensity={3.2} position={[3, 4, 6]} />
|
| 16 |
+
<directionalLight
|
| 17 |
+
color="#6ebfff"
|
| 18 |
+
intensity={1.5}
|
| 19 |
+
position={[-6, -3, -2]}
|
| 20 |
+
/>
|
| 21 |
+
<Environment preset="city" />
|
| 22 |
+
|
| 23 |
+
<Float floatIntensity={1.1} rotationIntensity={0.9} speed={1.45}>
|
| 24 |
+
<mesh position={[-4.6, 1.9, -4.2]} rotation={[0.95, 0.35, 0.55]}>
|
| 25 |
+
<torusGeometry args={[1.12, 0.16, 48, 160]} />
|
| 26 |
+
<MeshTransmissionMaterial
|
| 27 |
+
anisotropy={0.4}
|
| 28 |
+
chromaticAberration={0.09}
|
| 29 |
+
distortion={0.2}
|
| 30 |
+
roughness={0.03}
|
| 31 |
+
thickness={1.3}
|
| 32 |
+
transmission={1}
|
| 33 |
+
/>
|
| 34 |
+
</mesh>
|
| 35 |
+
</Float>
|
| 36 |
+
|
| 37 |
+
<Float floatIntensity={1.2} rotationIntensity={1.1} speed={1.55}>
|
| 38 |
+
<mesh position={[4.7, -1.7, -3.2]}>
|
| 39 |
+
<torusKnotGeometry args={[1.05, 0.24, 240, 32]} />
|
| 40 |
+
<MeshTransmissionMaterial
|
| 41 |
+
anisotropy={0.42}
|
| 42 |
+
chromaticAberration={0.1}
|
| 43 |
+
distortion={0.18}
|
| 44 |
+
roughness={0.03}
|
| 45 |
+
thickness={1.25}
|
| 46 |
+
transmission={1}
|
| 47 |
+
/>
|
| 48 |
+
</mesh>
|
| 49 |
+
</Float>
|
| 50 |
+
|
| 51 |
+
<Float floatIntensity={1.25} rotationIntensity={1} speed={1.7}>
|
| 52 |
+
<mesh position={[4.9, 2.3, -4.5]} rotation={[0.35, 0.6, 1.05]}>
|
| 53 |
+
<dodecahedronGeometry args={[0.92, 0]} />
|
| 54 |
+
<MeshTransmissionMaterial
|
| 55 |
+
anisotropy={0.35}
|
| 56 |
+
chromaticAberration={0.08}
|
| 57 |
+
distortion={0.14}
|
| 58 |
+
roughness={0.03}
|
| 59 |
+
thickness={1.1}
|
| 60 |
+
transmission={1}
|
| 61 |
+
/>
|
| 62 |
+
</mesh>
|
| 63 |
+
</Float>
|
| 64 |
+
|
| 65 |
+
<Float floatIntensity={1} rotationIntensity={0.85} speed={1.25}>
|
| 66 |
+
<mesh position={[-1.4, -2.3, -5.5]} rotation={[0.45, 0.75, 0.25]}>
|
| 67 |
+
<octahedronGeometry args={[1.18, 0]} />
|
| 68 |
+
<MeshTransmissionMaterial
|
| 69 |
+
anisotropy={0.32}
|
| 70 |
+
chromaticAberration={0.07}
|
| 71 |
+
distortion={0.12}
|
| 72 |
+
roughness={0.035}
|
| 73 |
+
thickness={1.15}
|
| 74 |
+
transmission={1}
|
| 75 |
+
/>
|
| 76 |
+
</mesh>
|
| 77 |
+
</Float>
|
| 78 |
+
|
| 79 |
+
<Float floatIntensity={0.85} rotationIntensity={0.65} speed={1.1}>
|
| 80 |
+
<mesh position={[0.4, 0.35, -6.1]}>
|
| 81 |
+
<sphereGeometry args={[1.38, 64, 64]} />
|
| 82 |
+
<MeshTransmissionMaterial
|
| 83 |
+
anisotropy={0.28}
|
| 84 |
+
chromaticAberration={0.06}
|
| 85 |
+
distortion={0.08}
|
| 86 |
+
roughness={0.02}
|
| 87 |
+
thickness={1.4}
|
| 88 |
+
transmission={1}
|
| 89 |
+
/>
|
| 90 |
+
</mesh>
|
| 91 |
+
</Float>
|
| 92 |
+
</>
|
| 93 |
+
);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
type FluidBackdropProps = {
|
| 97 |
+
subdued?: boolean;
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
function BackdropReadySignal({ onReady }: { onReady: () => void }) {
|
| 101 |
+
useEffect(() => {
|
| 102 |
+
let raf = window.requestAnimationFrame(() => {
|
| 103 |
+
raf = window.requestAnimationFrame(() => {
|
| 104 |
+
onReady();
|
| 105 |
+
});
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
return () => {
|
| 109 |
+
window.cancelAnimationFrame(raf);
|
| 110 |
+
};
|
| 111 |
+
}, [onReady]);
|
| 112 |
+
|
| 113 |
+
return null;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
export function FluidBackdrop({ subdued = false }: FluidBackdropProps) {
|
| 117 |
+
const backgroundColor = subdued ? "#060912" : DEFAULT_CONFIG.backgroundColor;
|
| 118 |
+
const [isReady, setIsReady] = useState(false);
|
| 119 |
+
|
| 120 |
+
return (
|
| 121 |
+
<div aria-hidden="true" className="fluid-backdrop">
|
| 122 |
+
<div className={`fluid-backdrop__scene ${isReady ? "is-ready" : ""}`}>
|
| 123 |
+
<Canvas
|
| 124 |
+
camera={{ fov: 42, position: [0, 0, 8] }}
|
| 125 |
+
dpr={[1, 2]}
|
| 126 |
+
gl={{ antialias: false }}
|
| 127 |
+
>
|
| 128 |
+
<color attach="background" args={[backgroundColor]} />
|
| 129 |
+
|
| 130 |
+
<Suspense fallback={null}>
|
| 131 |
+
<BackdropReadySignal onReady={() => setIsReady(true)} />
|
| 132 |
+
<BackdropGeometry />
|
| 133 |
+
|
| 134 |
+
<EffectComposer multisampling={0}>
|
| 135 |
+
<Fluid
|
| 136 |
+
{...DEFAULT_CONFIG}
|
| 137 |
+
backgroundColor={backgroundColor}
|
| 138 |
+
distortion={subdued ? 0.32 : 0.46}
|
| 139 |
+
fluidColor={subdued ? "#6fcff8" : "#96d8ff"}
|
| 140 |
+
intensity={subdued ? 1.35 : 1.9}
|
| 141 |
+
radius={0.24}
|
| 142 |
+
rainbow={false}
|
| 143 |
+
/>
|
| 144 |
+
</EffectComposer>
|
| 145 |
+
</Suspense>
|
| 146 |
+
</Canvas>
|
| 147 |
+
</div>
|
| 148 |
+
<div className={`fluid-backdrop__veil ${subdued ? "is-subdued" : ""}`} />
|
| 149 |
+
</div>
|
| 150 |
+
);
|
| 151 |
+
}
|
src/components/HfIcon.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
type HfIconProps = {
|
| 2 |
+
className?: string;
|
| 3 |
+
};
|
| 4 |
+
|
| 5 |
+
export function HfIcon({ className }: HfIconProps) {
|
| 6 |
+
return (
|
| 7 |
+
<svg
|
| 8 |
+
className={className}
|
| 9 |
+
fill="none"
|
| 10 |
+
viewBox="0 8 256 256"
|
| 11 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 12 |
+
>
|
| 13 |
+
<path
|
| 14 |
+
d="M230.721 172.7C230.183 170.673 229.313 168.75 228.146 167.008C228.396 166.091 228.587 165.159 228.714 164.217C229.543 158.241 227.471 152.77 223.567 148.537C221.452 146.225 219.185 144.698 216.784 143.761C218.36 137.018 219.157 130.117 219.161 123.193C219.161 120.03 218.982 116.932 218.682 113.88C218.526 112.356 218.337 110.836 218.115 109.32C217.428 104.847 216.408 100.431 215.064 96.11C214.183 93.2707 213.164 90.476 212.01 87.736C210.281 83.6782 208.262 79.75 205.969 75.982C204.465 73.475 202.827 71.0508 201.062 68.72C200.197 67.543 199.296 66.3938 198.358 65.274C195.58 61.898 192.561 58.7277 189.325 55.788C188.25 54.7997 187.145 53.8453 186.01 52.926C184.893 51.9943 183.751 51.0927 182.586 50.222C180.241 48.4766 177.818 46.8392 175.324 45.315C161.543 36.945 145.382 32.145 128.109 32.145C77.817 32.145 37.057 72.907 37.057 123.196C37.055 130.208 37.867 137.196 39.477 144.02C37.317 144.958 35.247 146.42 33.327 148.535C29.424 152.766 27.351 158.217 28.18 164.193C28.306 165.142 28.495 166.082 28.747 167.006C27.5811 168.749 26.7117 170.673 26.174 172.7C24.974 177.261 25.369 181.374 26.894 184.978C25.236 189.688 25.65 194.704 27.809 199.065C29.379 202.25 31.626 204.714 34.396 206.916C37.689 209.534 41.811 211.758 46.783 213.892C52.715 216.422 59.956 218.799 63.249 219.671C71.755 221.873 79.911 223.269 88.177 223.337C99.954 223.446 110.096 220.677 117.357 213.59C120.924 214.027 124.515 214.246 128.109 214.244C131.906 214.236 135.699 213.997 139.467 213.529C146.711 220.661 156.892 223.455 168.712 223.343C176.977 223.277 185.133 221.881 193.617 219.676C196.932 218.804 204.17 216.427 210.105 213.897C215.077 211.76 219.199 209.536 222.514 206.922C225.263 204.719 227.508 202.256 229.079 199.071C231.26 194.709 231.652 189.693 230.017 184.983C231.527 181.379 231.92 177.257 230.721 172.7Z"
|
| 15 |
+
fill="white"
|
| 16 |
+
/>
|
| 17 |
+
<path
|
| 18 |
+
d="M221.784 183.816C222.786 182.312 223.458 180.613 223.756 178.831C224.053 177.048 223.97 175.223 223.512 173.475C222.848 170.952 221.476 168.854 219.615 167.347C220.512 165.873 221.1 164.233 221.344 162.525C221.881 158.811 220.648 155.103 217.874 152.079C215.716 149.726 212.662 148.431 209.282 148.431C208.889 148.431 208.489 148.452 208.081 148.492C210.643 140.304 211.942 131.774 211.933 123.195C211.933 76.5231 174.097 38.6851 127.424 38.6851C80.75 38.6851 42.9099 76.5191 42.9099 123.195C42.9015 131.752 44.1936 140.261 46.742 148.43H46.6519C43.2719 148.43 40.219 149.724 38.06 152.077C35.287 155.098 34.0529 158.81 34.5899 162.523C34.8346 164.231 35.4231 165.872 36.3199 167.346C34.4579 168.852 33.086 170.95 32.422 173.473C31.9642 175.222 31.8817 177.047 32.1799 178.83C32.4781 180.612 33.1501 182.312 34.1519 183.816C33.9739 184.094 33.8099 184.381 33.6549 184.676C31.9849 187.847 31.877 191.43 33.352 194.767C35.588 199.824 41.1419 203.808 51.9289 208.085C58.6359 210.745 64.779 212.446 64.833 212.461C73.705 214.762 81.729 215.931 88.675 215.931C100.081 215.931 108.591 212.811 114.026 206.647C123.222 208.106 132.594 208.052 141.773 206.489C147.201 212.757 155.76 215.931 167.262 215.931C174.208 215.931 182.232 214.762 191.103 212.461C191.158 212.446 197.298 210.745 204.008 208.085C214.795 203.808 220.35 199.824 222.585 194.767C224.059 191.43 223.952 187.847 222.281 184.676C222.129 184.379 221.961 184.091 221.784 183.816Z"
|
| 19 |
+
fill="#FFD21E"
|
| 20 |
+
/>
|
| 21 |
+
<path
|
| 22 |
+
clipRule="evenodd"
|
| 23 |
+
d="M152.047 102.567C153.229 102.985 154.108 104.257 154.944 105.468C156.074 107.104 157.126 108.627 158.74 107.769C160.644 106.756 162.205 105.202 163.225 103.302C164.246 101.402 164.681 99.2427 164.475 97.096C164.321 95.4908 163.813 93.9398 162.987 92.5548C162.161 91.1697 161.038 89.985 159.7 89.0862C158.361 88.1874 156.839 87.5968 155.245 87.3569C153.65 87.117 152.022 87.2339 150.478 87.699C148.934 88.1639 147.513 88.9653 146.316 90.0455C145.119 91.1257 144.176 92.4578 143.556 93.946C142.936 95.4342 142.653 97.0415 142.728 98.652C142.804 100.263 143.235 101.836 143.992 103.26C144.74 104.667 146.4 104.003 148.152 103.302C149.525 102.753 150.956 102.181 152.047 102.567ZM100.672 102.567C99.49 102.985 98.611 104.258 97.775 105.468C96.645 107.105 95.592 108.627 93.979 107.769C91.5845 106.501 89.7482 104.386 88.8278 101.838C87.9075 99.2895 87.9692 96.4896 89.0008 93.9841C90.0324 91.4786 91.9601 89.4471 94.408 88.2855C96.856 87.1239 99.6488 86.9156 102.242 87.701C104.307 88.3228 106.141 89.5427 107.513 91.2065C108.885 92.8704 109.732 94.9035 109.949 97.049C110.165 99.1945 109.74 101.356 108.728 103.26C107.979 104.667 106.319 104.003 104.567 103.303C103.193 102.753 101.764 102.181 100.672 102.567ZM144.099 149.318C152.242 142.903 155.233 132.429 155.233 125.977C155.233 120.877 151.802 122.482 146.309 125.202L145.999 125.355C140.957 127.852 134.245 131.177 126.877 131.177C119.508 131.177 112.796 127.852 107.755 125.354C102.084 122.545 98.527 120.783 98.527 125.978C98.527 132.634 101.709 143.563 110.443 149.912C111.596 147.573 113.219 145.497 115.211 143.813C117.202 142.129 119.52 140.874 122.018 140.126C122.89 139.866 123.788 141.367 124.707 142.904C125.594 144.386 126.501 145.902 127.423 145.902C128.406 145.902 129.371 144.408 130.314 142.95C131.299 141.425 132.26 139.94 133.189 140.237C137.864 141.738 141.775 144.993 144.099 149.318Z"
|
| 24 |
+
fill="#32343D"
|
| 25 |
+
fillRule="evenodd"
|
| 26 |
+
/>
|
| 27 |
+
<path
|
| 28 |
+
d="M144.097 149.317C139.856 152.659 134.219 154.9 126.878 154.9C119.981 154.9 114.587 152.922 110.443 149.911C111.596 147.572 113.219 145.495 115.211 143.812C117.202 142.128 119.52 140.873 122.018 140.125C123.73 139.614 125.545 145.901 127.423 145.901C129.433 145.901 131.37 139.655 133.189 140.236C137.863 141.738 141.773 144.993 144.097 149.317Z"
|
| 29 |
+
fill="#FF323D"
|
| 30 |
+
/>
|
| 31 |
+
<path
|
| 32 |
+
clipRule="evenodd"
|
| 33 |
+
d="M81.2 111.64C80.2312 112.288 79.1173 112.687 77.9572 112.801C76.7971 112.916 75.6267 112.742 74.55 112.295C73.6893 111.94 72.9072 111.418 72.2488 110.759C71.5903 110.101 71.0684 109.319 70.713 108.458C70.267 107.381 70.0935 106.211 70.2082 105.051C70.3228 103.891 70.7219 102.777 71.37 101.808C72.1488 100.642 73.2558 99.7333 74.5512 99.1967C75.8466 98.6601 77.272 98.5197 78.6471 98.7935C80.0223 99.0672 81.2853 99.7427 82.2764 100.734C83.2675 101.726 83.9422 102.99 84.215 104.365C84.4883 105.74 84.3477 107.165 83.8113 108.46C83.2748 109.755 82.3654 110.861 81.2 111.64ZM182.613 111.64C181.644 112.288 180.53 112.687 179.37 112.801C178.209 112.916 177.039 112.742 175.962 112.295C175.101 111.939 174.319 111.418 173.661 110.759C173.003 110.101 172.481 109.319 172.125 108.458C171.68 107.381 171.507 106.211 171.621 105.051C171.736 103.891 172.135 102.777 172.782 101.808C173.364 100.936 174.133 100.205 175.032 99.6658C175.931 99.1269 176.938 98.7942 177.981 98.6917C179.025 98.5891 180.078 98.7193 181.064 99.0728C182.051 99.4264 182.947 99.9944 183.688 100.736C184.68 101.727 185.355 102.99 185.628 104.365C185.902 105.74 185.761 107.165 185.224 108.46C184.687 109.755 183.779 110.861 182.613 111.64Z"
|
| 34 |
+
fill="#FFAD03"
|
| 35 |
+
fillRule="evenodd"
|
| 36 |
+
/>
|
| 37 |
+
</svg>
|
| 38 |
+
);
|
| 39 |
+
}
|
src/context/VLMContext.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createContext, useContext } from "react";
|
| 2 |
+
|
| 3 |
+
export type LoadState = {
|
| 4 |
+
error: string | null;
|
| 5 |
+
message: string;
|
| 6 |
+
progress: number;
|
| 7 |
+
status: "idle" | "loading" | "ready" | "error";
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export type VLMContextValue = LoadState & {
|
| 11 |
+
generateCaption: (request: {
|
| 12 |
+
frame: ImageData;
|
| 13 |
+
onStream?: (text: string) => void;
|
| 14 |
+
prompt: string;
|
| 15 |
+
}) => Promise<string>;
|
| 16 |
+
loadModel: () => Promise<void>;
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
export const VLMContext = createContext<VLMContextValue | null>(null);
|
| 20 |
+
|
| 21 |
+
export function useVLM() {
|
| 22 |
+
const context = useContext(VLMContext);
|
| 23 |
+
|
| 24 |
+
if (!context) {
|
| 25 |
+
throw new Error("useVLM must be used within a VLMProvider.");
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
return context;
|
| 29 |
+
}
|
src/context/VLMProvider.tsx
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
AutoModelForImageTextToText,
|
| 3 |
+
AutoProcessor,
|
| 4 |
+
RawImage,
|
| 5 |
+
TextStreamer,
|
| 6 |
+
type ProgressInfo,
|
| 7 |
+
type Tensor,
|
| 8 |
+
} from "@huggingface/transformers";
|
| 9 |
+
import { useCallback, useRef, useState, type PropsWithChildren } from "react";
|
| 10 |
+
import { VLMContext, type LoadState } from "./VLMContext";
|
| 11 |
+
|
| 12 |
+
const MODEL_ID = "onnx-community/LFM2-VL-450M-ONNX";
|
| 13 |
+
const MODEL_FILE_COUNT = 3;
|
| 14 |
+
const MAX_NEW_TOKENS = 128;
|
| 15 |
+
|
| 16 |
+
type CaptionRequest = {
|
| 17 |
+
frame: ImageData;
|
| 18 |
+
onStream?: (text: string) => void;
|
| 19 |
+
prompt: string;
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
type ProcessorType = Awaited<ReturnType<typeof AutoProcessor.from_pretrained>>;
|
| 23 |
+
type ModelType = Awaited<
|
| 24 |
+
ReturnType<typeof AutoModelForImageTextToText.from_pretrained>
|
| 25 |
+
>;
|
| 26 |
+
|
| 27 |
+
const initialLoadState: LoadState = {
|
| 28 |
+
error: null,
|
| 29 |
+
message: "Downloading...",
|
| 30 |
+
progress: 0,
|
| 31 |
+
status: "idle",
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
function normalizeText(text: string) {
|
| 35 |
+
return text.replace(/\s+/g, " ").trim();
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function getErrorMessage(error: unknown) {
|
| 39 |
+
if (error instanceof Error) {
|
| 40 |
+
return error.message;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
return "The model could not be loaded.";
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export function VLMProvider({ children }: PropsWithChildren) {
|
| 47 |
+
const [loadState, setLoadState] = useState(initialLoadState);
|
| 48 |
+
const processorRef = useRef<ProcessorType | null>(null);
|
| 49 |
+
const modelRef = useRef<ModelType | null>(null);
|
| 50 |
+
const loadPromiseRef = useRef<Promise<void> | null>(null);
|
| 51 |
+
const generationInFlightRef = useRef(false);
|
| 52 |
+
|
| 53 |
+
const setLoadProgress = useCallback((state: Partial<LoadState>) => {
|
| 54 |
+
setLoadState((current) => ({
|
| 55 |
+
...current,
|
| 56 |
+
...state,
|
| 57 |
+
}));
|
| 58 |
+
}, []);
|
| 59 |
+
|
| 60 |
+
const loadModel = useCallback(async () => {
|
| 61 |
+
if (processorRef.current && modelRef.current) {
|
| 62 |
+
setLoadProgress({
|
| 63 |
+
error: null,
|
| 64 |
+
message: "Model ready",
|
| 65 |
+
progress: 100,
|
| 66 |
+
status: "ready",
|
| 67 |
+
});
|
| 68 |
+
return;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
if (loadPromiseRef.current) {
|
| 72 |
+
return loadPromiseRef.current;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
if (!("gpu" in navigator)) {
|
| 76 |
+
const message = "WebGPU is not available in this browser.";
|
| 77 |
+
setLoadProgress({
|
| 78 |
+
error: message,
|
| 79 |
+
message: "WebGPU unavailable",
|
| 80 |
+
progress: 0,
|
| 81 |
+
status: "error",
|
| 82 |
+
});
|
| 83 |
+
throw new Error(message);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
loadPromiseRef.current = (async () => {
|
| 87 |
+
try {
|
| 88 |
+
const processor = await AutoProcessor.from_pretrained(MODEL_ID);
|
| 89 |
+
processorRef.current = processor;
|
| 90 |
+
|
| 91 |
+
setLoadProgress({
|
| 92 |
+
message: "Downloading...",
|
| 93 |
+
progress: 0,
|
| 94 |
+
status: "loading",
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
const progressMap = new Map<string, number>();
|
| 98 |
+
const progressCallback = (info: ProgressInfo) => {
|
| 99 |
+
if (
|
| 100 |
+
info.status !== "progress" ||
|
| 101 |
+
!info.file.endsWith(".onnx_data") ||
|
| 102 |
+
info.total === 0
|
| 103 |
+
) {
|
| 104 |
+
return;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
progressMap.set(info.file, info.loaded / info.total);
|
| 108 |
+
|
| 109 |
+
const totalProgress =
|
| 110 |
+
(Array.from(progressMap.values()).reduce(
|
| 111 |
+
(sum, value) => sum + value,
|
| 112 |
+
0,
|
| 113 |
+
) /
|
| 114 |
+
MODEL_FILE_COUNT) *
|
| 115 |
+
100;
|
| 116 |
+
|
| 117 |
+
setLoadProgress({
|
| 118 |
+
message: "Downloading...",
|
| 119 |
+
progress: totalProgress,
|
| 120 |
+
status: "loading",
|
| 121 |
+
});
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
modelRef.current = await AutoModelForImageTextToText.from_pretrained(
|
| 125 |
+
MODEL_ID,
|
| 126 |
+
{
|
| 127 |
+
device: "webgpu",
|
| 128 |
+
dtype: {
|
| 129 |
+
vision_encoder: "fp16",
|
| 130 |
+
embed_tokens: "fp16",
|
| 131 |
+
decoder_model_merged: "q4f16",
|
| 132 |
+
},
|
| 133 |
+
progress_callback: progressCallback,
|
| 134 |
+
},
|
| 135 |
+
);
|
| 136 |
+
|
| 137 |
+
setLoadProgress({
|
| 138 |
+
error: null,
|
| 139 |
+
message: "Model ready",
|
| 140 |
+
progress: 100,
|
| 141 |
+
status: "ready",
|
| 142 |
+
});
|
| 143 |
+
} catch (error) {
|
| 144 |
+
const message = getErrorMessage(error);
|
| 145 |
+
setLoadProgress({
|
| 146 |
+
error: message,
|
| 147 |
+
message: "Unable to load model",
|
| 148 |
+
progress: 0,
|
| 149 |
+
status: "error",
|
| 150 |
+
});
|
| 151 |
+
throw error;
|
| 152 |
+
} finally {
|
| 153 |
+
loadPromiseRef.current = null;
|
| 154 |
+
}
|
| 155 |
+
})();
|
| 156 |
+
|
| 157 |
+
return loadPromiseRef.current;
|
| 158 |
+
}, [setLoadProgress]);
|
| 159 |
+
|
| 160 |
+
const generateCaption = useCallback(
|
| 161 |
+
async ({ frame, onStream, prompt }: CaptionRequest) => {
|
| 162 |
+
const processor = processorRef.current;
|
| 163 |
+
const model = modelRef.current;
|
| 164 |
+
|
| 165 |
+
if (!processor || !model || !processor.tokenizer) {
|
| 166 |
+
throw new Error("The model is not ready yet.");
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
if (generationInFlightRef.current) {
|
| 170 |
+
return "";
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
generationInFlightRef.current = true;
|
| 174 |
+
|
| 175 |
+
try {
|
| 176 |
+
const messages = [
|
| 177 |
+
{
|
| 178 |
+
content: [
|
| 179 |
+
{ type: "image" },
|
| 180 |
+
{ text: normalizeText(prompt), type: "text" },
|
| 181 |
+
],
|
| 182 |
+
role: "user",
|
| 183 |
+
},
|
| 184 |
+
];
|
| 185 |
+
|
| 186 |
+
const chatPrompt = processor.apply_chat_template(messages, {
|
| 187 |
+
add_generation_prompt: true,
|
| 188 |
+
});
|
| 189 |
+
const rawFrame = new RawImage(frame.data, frame.width, frame.height, 4);
|
| 190 |
+
|
| 191 |
+
const inputs = await processor(rawFrame, chatPrompt, {
|
| 192 |
+
add_special_tokens: false,
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
let streamedText = "";
|
| 196 |
+
const streamer = new TextStreamer(processor.tokenizer, {
|
| 197 |
+
callback_function: (text) => {
|
| 198 |
+
streamedText += text;
|
| 199 |
+
const normalized = normalizeText(streamedText);
|
| 200 |
+
|
| 201 |
+
if (normalized.length > 0) {
|
| 202 |
+
onStream?.(normalized);
|
| 203 |
+
}
|
| 204 |
+
},
|
| 205 |
+
skip_prompt: true,
|
| 206 |
+
skip_special_tokens: true,
|
| 207 |
+
});
|
| 208 |
+
|
| 209 |
+
const outputs = (await model.generate({
|
| 210 |
+
...inputs,
|
| 211 |
+
do_sample: false,
|
| 212 |
+
max_new_tokens: MAX_NEW_TOKENS,
|
| 213 |
+
repetition_penalty: 1.08,
|
| 214 |
+
streamer,
|
| 215 |
+
})) as Tensor;
|
| 216 |
+
|
| 217 |
+
const inputLength = inputs.input_ids.dims.at(-1) ?? 0;
|
| 218 |
+
const generated = outputs.slice(null, [inputLength, null]);
|
| 219 |
+
const [decoded] = processor.batch_decode(generated, {
|
| 220 |
+
skip_special_tokens: true,
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
const finalCaption = normalizeText(decoded ?? streamedText);
|
| 224 |
+
if (finalCaption.length > 0) {
|
| 225 |
+
onStream?.(finalCaption);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
return finalCaption;
|
| 229 |
+
} finally {
|
| 230 |
+
generationInFlightRef.current = false;
|
| 231 |
+
}
|
| 232 |
+
},
|
| 233 |
+
[],
|
| 234 |
+
);
|
| 235 |
+
|
| 236 |
+
return (
|
| 237 |
+
<VLMContext.Provider
|
| 238 |
+
value={{
|
| 239 |
+
...loadState,
|
| 240 |
+
generateCaption,
|
| 241 |
+
loadModel,
|
| 242 |
+
}}
|
| 243 |
+
>
|
| 244 |
+
{children}
|
| 245 |
+
</VLMContext.Provider>
|
| 246 |
+
);
|
| 247 |
+
}
|
src/index.css
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@font-face {
|
| 2 |
+
font-family: "Sohne";
|
| 3 |
+
font-style: normal;
|
| 4 |
+
font-weight: 400;
|
| 5 |
+
src: url("/fonts/Söhne/Söhne-Buch.otf") format("opentype");
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
@font-face {
|
| 9 |
+
font-family: "Sohne";
|
| 10 |
+
font-style: normal;
|
| 11 |
+
font-weight: 300;
|
| 12 |
+
src: url("/fonts/Söhne/Söhne-Leicht.otf") format("opentype");
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
@font-face {
|
| 16 |
+
font-family: "Sohne";
|
| 17 |
+
font-style: normal;
|
| 18 |
+
font-weight: 700;
|
| 19 |
+
src: url("/fonts/Söhne/Söhne-Kräftig.otf") format("opentype");
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
@font-face {
|
| 23 |
+
font-family: "JetBrains Mono";
|
| 24 |
+
font-style: normal;
|
| 25 |
+
font-weight: 100 800;
|
| 26 |
+
src: url("/fonts/JetBrains/JetBrainsMono-VariableFont_wght.ttf")
|
| 27 |
+
format("truetype");
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
:root {
|
| 31 |
+
--bg: #050814;
|
| 32 |
+
--bg-soft: rgba(8, 13, 25, 0.72);
|
| 33 |
+
--panel: rgba(10, 15, 28, 0.68);
|
| 34 |
+
--panel-strong: rgba(255, 255, 255, 0.12);
|
| 35 |
+
--line: rgba(255, 255, 255, 0.12);
|
| 36 |
+
--line-strong: rgba(255, 255, 255, 0.22);
|
| 37 |
+
--text: #f3f7ff;
|
| 38 |
+
--text-soft: rgba(243, 247, 255, 0.72);
|
| 39 |
+
--text-muted: rgba(243, 247, 255, 0.54);
|
| 40 |
+
--accent: #9de0ff;
|
| 41 |
+
--accent-strong: #d7f4ff;
|
| 42 |
+
--shadow: 0 28px 80px rgba(2, 6, 17, 0.35);
|
| 43 |
+
--font-body: "Sohne", sans-serif;
|
| 44 |
+
--font-mono: "JetBrains Mono", monospace;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
* {
|
| 48 |
+
box-sizing: border-box;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
html,
|
| 52 |
+
body,
|
| 53 |
+
#root {
|
| 54 |
+
min-height: 100%;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
html {
|
| 58 |
+
background: var(--bg);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
body {
|
| 62 |
+
margin: 0;
|
| 63 |
+
background: var(--bg);
|
| 64 |
+
color: var(--text);
|
| 65 |
+
font-family: var(--font-body);
|
| 66 |
+
font-synthesis: none;
|
| 67 |
+
overflow: hidden;
|
| 68 |
+
text-rendering: optimizeLegibility;
|
| 69 |
+
-moz-osx-font-smoothing: grayscale;
|
| 70 |
+
-webkit-font-smoothing: antialiased;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
button,
|
| 74 |
+
input,
|
| 75 |
+
textarea {
|
| 76 |
+
color: inherit;
|
| 77 |
+
font: inherit;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
button {
|
| 81 |
+
border: 0;
|
| 82 |
+
cursor: pointer;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
img {
|
| 86 |
+
display: block;
|
| 87 |
+
max-width: 100%;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.hidden-file-input,
|
| 91 |
+
.capture-canvas {
|
| 92 |
+
display: none;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.fluid-backdrop {
|
| 96 |
+
position: fixed;
|
| 97 |
+
inset: 0;
|
| 98 |
+
background: #000;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.fluid-backdrop canvas {
|
| 102 |
+
display: block;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.fluid-backdrop__scene {
|
| 106 |
+
width: 100%;
|
| 107 |
+
height: 100%;
|
| 108 |
+
opacity: 0;
|
| 109 |
+
transition:
|
| 110 |
+
opacity 900ms cubic-bezier(0.16, 1, 0.3, 1),
|
| 111 |
+
transform 900ms cubic-bezier(0.16, 1, 0.3, 1);
|
| 112 |
+
transform: scale(1.02);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.fluid-backdrop__scene.is-ready {
|
| 116 |
+
opacity: 1;
|
| 117 |
+
transform: scale(1);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.fluid-backdrop__veil {
|
| 121 |
+
position: absolute;
|
| 122 |
+
inset: 0;
|
| 123 |
+
background: rgba(0, 0, 0, 0);
|
| 124 |
+
pointer-events: none;
|
| 125 |
+
transition: background-color 320ms ease;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.fluid-backdrop__veil.is-subdued {
|
| 129 |
+
background: rgba(3, 7, 16, 0.26);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.landing-scene,
|
| 133 |
+
.scene-shell,
|
| 134 |
+
.capture-scene {
|
| 135 |
+
position: relative;
|
| 136 |
+
z-index: 1;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.landing-scene {
|
| 140 |
+
width: 100%;
|
| 141 |
+
min-height: 100vh;
|
| 142 |
+
padding: 28px;
|
| 143 |
+
background: transparent;
|
| 144 |
+
text-align: left;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.landing-inner {
|
| 148 |
+
width: min(1120px, 100%);
|
| 149 |
+
min-height: calc(100vh - 56px);
|
| 150 |
+
margin: 0 auto;
|
| 151 |
+
display: flex;
|
| 152 |
+
flex-direction: column;
|
| 153 |
+
justify-content: space-between;
|
| 154 |
+
gap: 32px;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.brand-mark {
|
| 158 |
+
display: inline-flex;
|
| 159 |
+
align-items: center;
|
| 160 |
+
gap: 14px;
|
| 161 |
+
width: fit-content;
|
| 162 |
+
padding: 11px 17px 11px 11px;
|
| 163 |
+
border: 1px solid var(--line);
|
| 164 |
+
border-radius: 999px;
|
| 165 |
+
background: rgba(7, 11, 22, 0.54);
|
| 166 |
+
box-shadow: var(--shadow);
|
| 167 |
+
backdrop-filter: blur(18px);
|
| 168 |
+
-webkit-backdrop-filter: blur(18px);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.brand-logo {
|
| 172 |
+
width: 36px;
|
| 173 |
+
height: 36px;
|
| 174 |
+
object-fit: contain;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.brand-copy {
|
| 178 |
+
display: flex;
|
| 179 |
+
flex-direction: column;
|
| 180 |
+
gap: 3px;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.brand-copy span {
|
| 184 |
+
color: var(--text-muted);
|
| 185 |
+
font-family: var(--font-mono);
|
| 186 |
+
font-size: 0.7rem;
|
| 187 |
+
letter-spacing: 0.18em;
|
| 188 |
+
text-transform: uppercase;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.brand-copy strong {
|
| 192 |
+
font-size: 0.95rem;
|
| 193 |
+
font-weight: 700;
|
| 194 |
+
letter-spacing: 0.01em;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.hero-copy {
|
| 198 |
+
max-width: 760px;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.eyebrow,
|
| 202 |
+
.dock-label {
|
| 203 |
+
display: inline-block;
|
| 204 |
+
color: var(--accent);
|
| 205 |
+
font-family: var(--font-mono);
|
| 206 |
+
font-size: 0.73rem;
|
| 207 |
+
letter-spacing: 0.22em;
|
| 208 |
+
text-transform: uppercase;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.hero-copy h1,
|
| 212 |
+
.loading-card h2,
|
| 213 |
+
.source-card h2 {
|
| 214 |
+
margin: 18px 0 0;
|
| 215 |
+
font-size: clamp(3.4rem, 9vw, 7.8rem);
|
| 216 |
+
font-weight: 700;
|
| 217 |
+
letter-spacing: -0.06em;
|
| 218 |
+
line-height: 0.95;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
.hero-copy p,
|
| 222 |
+
.source-card p {
|
| 223 |
+
max-width: 620px;
|
| 224 |
+
margin: 20px 0 0;
|
| 225 |
+
color: var(--text-soft);
|
| 226 |
+
font-size: clamp(1.05rem, 2.6vw, 1.42rem);
|
| 227 |
+
line-height: 1.5;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.hero-inline-icon {
|
| 231 |
+
display: inline-block;
|
| 232 |
+
width: 1.6rem;
|
| 233 |
+
height: 1.6rem;
|
| 234 |
+
margin: 0 0.25rem 1px 0.35rem;
|
| 235 |
+
vertical-align: text-bottom;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.hero-inline-wordmark {
|
| 239 |
+
display: inline-block;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.begin-prompt {
|
| 243 |
+
display: inline-flex;
|
| 244 |
+
align-items: center;
|
| 245 |
+
gap: 12px;
|
| 246 |
+
width: fit-content;
|
| 247 |
+
margin: 0 auto;
|
| 248 |
+
padding: 14px 20px;
|
| 249 |
+
border: 1px solid var(--line-strong);
|
| 250 |
+
border-radius: 999px;
|
| 251 |
+
background: rgba(255, 255, 255, 0.08);
|
| 252 |
+
color: var(--accent-strong);
|
| 253 |
+
font-size: 0.96rem;
|
| 254 |
+
letter-spacing: 0.02em;
|
| 255 |
+
backdrop-filter: blur(16px);
|
| 256 |
+
-webkit-backdrop-filter: blur(16px);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.scene-shell {
|
| 260 |
+
width: 100%;
|
| 261 |
+
min-height: 100vh;
|
| 262 |
+
padding: 28px;
|
| 263 |
+
display: flex;
|
| 264 |
+
flex-direction: column;
|
| 265 |
+
justify-content: center;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.scene-shell--centered {
|
| 269 |
+
align-items: center;
|
| 270 |
+
gap: 28px;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.scene-header {
|
| 274 |
+
position: absolute;
|
| 275 |
+
top: 28px;
|
| 276 |
+
left: 28px;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.loading-card,
|
| 280 |
+
.source-card,
|
| 281 |
+
.prompt-dock,
|
| 282 |
+
.floating-alert {
|
| 283 |
+
border: 1px solid var(--line);
|
| 284 |
+
background: var(--panel);
|
| 285 |
+
box-shadow: var(--shadow);
|
| 286 |
+
backdrop-filter: blur(24px);
|
| 287 |
+
-webkit-backdrop-filter: blur(24px);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.loading-card {
|
| 291 |
+
width: min(520px, 100%);
|
| 292 |
+
padding: 30px;
|
| 293 |
+
border-radius: 32px;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.loading-card h2 {
|
| 297 |
+
margin-top: 16px;
|
| 298 |
+
font-size: clamp(2.3rem, 6vw, 4.3rem);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
.loading-card p {
|
| 302 |
+
margin: 16px 0 0;
|
| 303 |
+
color: var(--text-soft);
|
| 304 |
+
font-family: var(--font-mono);
|
| 305 |
+
font-size: 0.9rem;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
.progress-track {
|
| 309 |
+
width: 100%;
|
| 310 |
+
height: 12px;
|
| 311 |
+
margin-top: 24px;
|
| 312 |
+
overflow: hidden;
|
| 313 |
+
border-radius: 999px;
|
| 314 |
+
background: rgba(255, 255, 255, 0.08);
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
.progress-fill {
|
| 318 |
+
height: 100%;
|
| 319 |
+
border-radius: inherit;
|
| 320 |
+
background: linear-gradient(
|
| 321 |
+
90deg,
|
| 322 |
+
rgba(128, 222, 255, 0.8),
|
| 323 |
+
rgba(245, 251, 255, 0.98)
|
| 324 |
+
);
|
| 325 |
+
box-shadow: 0 0 40px rgba(156, 228, 255, 0.4);
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.source-card {
|
| 329 |
+
width: min(860px, 100%);
|
| 330 |
+
margin: 0 auto;
|
| 331 |
+
padding: 32px;
|
| 332 |
+
border-radius: 36px;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.source-card h2 {
|
| 336 |
+
margin-top: 16px;
|
| 337 |
+
font-size: clamp(2.2rem, 5vw, 4.8rem);
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
.source-grid {
|
| 341 |
+
display: grid;
|
| 342 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 343 |
+
gap: 16px;
|
| 344 |
+
margin-top: 30px;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.source-option,
|
| 348 |
+
.ghost-button,
|
| 349 |
+
.primary-button,
|
| 350 |
+
.prompt-chip {
|
| 351 |
+
transition:
|
| 352 |
+
transform 180ms ease,
|
| 353 |
+
border-color 180ms ease,
|
| 354 |
+
background-color 180ms ease,
|
| 355 |
+
opacity 180ms ease;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.source-option {
|
| 359 |
+
display: flex;
|
| 360 |
+
padding: 26px;
|
| 361 |
+
border: 1px solid var(--line);
|
| 362 |
+
border-radius: 28px;
|
| 363 |
+
background: rgba(255, 255, 255, 0.05);
|
| 364 |
+
flex-direction: column;
|
| 365 |
+
justify-content: space-between;
|
| 366 |
+
text-align: left;
|
| 367 |
+
gap: 8px;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.source-option strong {
|
| 371 |
+
font-size: 1.45rem;
|
| 372 |
+
font-weight: 700;
|
| 373 |
+
letter-spacing: -0.02em;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.source-option__header {
|
| 377 |
+
display: inline-flex;
|
| 378 |
+
align-items: center;
|
| 379 |
+
gap: 14px;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.source-option__icon,
|
| 383 |
+
.button-icon {
|
| 384 |
+
color: var(--accent);
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.source-option span {
|
| 388 |
+
color: var(--text-soft);
|
| 389 |
+
font-size: 1.06rem;
|
| 390 |
+
line-height: 1.5;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
.source-option:hover,
|
| 394 |
+
.ghost-button:hover,
|
| 395 |
+
.primary-button:hover,
|
| 396 |
+
.prompt-chip:hover {
|
| 397 |
+
transform: translateY(-2px);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
.capture-scene {
|
| 401 |
+
min-height: 100vh;
|
| 402 |
+
background: #03050a;
|
| 403 |
+
overflow: hidden;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.capture-video,
|
| 407 |
+
.capture-scrim {
|
| 408 |
+
position: absolute;
|
| 409 |
+
inset: 0;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.capture-video {
|
| 413 |
+
width: 100%;
|
| 414 |
+
height: 100%;
|
| 415 |
+
object-fit: cover;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.capture-scrim {
|
| 419 |
+
background: linear-gradient(
|
| 420 |
+
180deg,
|
| 421 |
+
rgba(3, 5, 10, 0.15),
|
| 422 |
+
rgba(3, 5, 10, 0.28) 36%,
|
| 423 |
+
rgba(3, 5, 10, 0.68)
|
| 424 |
+
),
|
| 425 |
+
radial-gradient(
|
| 426 |
+
circle at top left,
|
| 427 |
+
rgba(146, 220, 255, 0.2),
|
| 428 |
+
transparent 34%
|
| 429 |
+
),
|
| 430 |
+
radial-gradient(
|
| 431 |
+
circle at bottom right,
|
| 432 |
+
rgba(255, 255, 255, 0.09),
|
| 433 |
+
transparent 24%
|
| 434 |
+
);
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.capture-toolbar {
|
| 438 |
+
position: relative;
|
| 439 |
+
z-index: 2;
|
| 440 |
+
display: flex;
|
| 441 |
+
justify-content: space-between;
|
| 442 |
+
gap: 16px;
|
| 443 |
+
padding: 24px;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.capture-toolbar__left {
|
| 447 |
+
display: flex;
|
| 448 |
+
align-items: center;
|
| 449 |
+
gap: 14px;
|
| 450 |
+
flex-wrap: wrap;
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
.status-pill,
|
| 454 |
+
.ghost-button,
|
| 455 |
+
.primary-button {
|
| 456 |
+
display: inline-flex;
|
| 457 |
+
align-items: center;
|
| 458 |
+
gap: 10px;
|
| 459 |
+
padding: 12px 16px;
|
| 460 |
+
border: 1px solid var(--line);
|
| 461 |
+
border-radius: 999px;
|
| 462 |
+
background: rgba(8, 13, 24, 0.44);
|
| 463 |
+
backdrop-filter: blur(16px);
|
| 464 |
+
-webkit-backdrop-filter: blur(16px);
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
.button-icon {
|
| 468 |
+
flex-shrink: 0;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.status-pill {
|
| 472 |
+
color: var(--text-soft);
|
| 473 |
+
font-family: var(--font-mono);
|
| 474 |
+
font-size: 0.78rem;
|
| 475 |
+
letter-spacing: 0.08em;
|
| 476 |
+
text-transform: uppercase;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
.status-dot {
|
| 480 |
+
width: 8px;
|
| 481 |
+
height: 8px;
|
| 482 |
+
border-radius: 999px;
|
| 483 |
+
background: rgba(255, 255, 255, 0.3);
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
.status-dot.is-live {
|
| 487 |
+
background: #9ce5ff;
|
| 488 |
+
box-shadow: 0 0 16px rgba(156, 229, 255, 0.7);
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
.toolbar-actions {
|
| 492 |
+
display: flex;
|
| 493 |
+
justify-content: flex-end;
|
| 494 |
+
gap: 10px;
|
| 495 |
+
flex-wrap: wrap;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
.ghost-button {
|
| 499 |
+
color: var(--text);
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
.ghost-button--small {
|
| 503 |
+
padding: 8px 12px;
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
.primary-button {
|
| 507 |
+
width: fit-content;
|
| 508 |
+
margin-top: 18px;
|
| 509 |
+
background: rgba(255, 255, 255, 0.12);
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
.floating-alert {
|
| 513 |
+
position: absolute;
|
| 514 |
+
top: 96px;
|
| 515 |
+
left: 24px;
|
| 516 |
+
z-index: 3;
|
| 517 |
+
display: inline-flex;
|
| 518 |
+
align-items: center;
|
| 519 |
+
gap: 14px;
|
| 520 |
+
max-width: min(620px, calc(100vw - 48px));
|
| 521 |
+
padding: 14px 16px;
|
| 522 |
+
border-radius: 20px;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
.floating-alert--secondary {
|
| 526 |
+
top: 156px;
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
.error-banner {
|
| 530 |
+
margin-top: 18px;
|
| 531 |
+
padding: 14px 16px;
|
| 532 |
+
border: 1px solid rgba(255, 160, 160, 0.3);
|
| 533 |
+
border-radius: 18px;
|
| 534 |
+
background: rgba(255, 120, 120, 0.08);
|
| 535 |
+
color: #ffd9d9;
|
| 536 |
+
line-height: 1.5;
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
.prompt-dock,
|
| 540 |
+
.capture-side-rail {
|
| 541 |
+
position: absolute;
|
| 542 |
+
bottom: 24px;
|
| 543 |
+
z-index: 2;
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
.prompt-dock {
|
| 547 |
+
left: 24px;
|
| 548 |
+
width: min(520px, calc(100vw - 48px));
|
| 549 |
+
padding: 18px;
|
| 550 |
+
border-radius: 30px;
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
.prompt-chip-row {
|
| 554 |
+
display: flex;
|
| 555 |
+
flex-wrap: wrap;
|
| 556 |
+
gap: 10px;
|
| 557 |
+
margin: 14px 0 16px;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
.prompt-chip {
|
| 561 |
+
padding: 10px 14px;
|
| 562 |
+
border: 1px solid var(--line);
|
| 563 |
+
border-radius: 999px;
|
| 564 |
+
background: rgba(255, 255, 255, 0.05);
|
| 565 |
+
color: var(--text-soft);
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
.prompt-chip.is-active {
|
| 569 |
+
border-color: rgba(157, 224, 255, 0.42);
|
| 570 |
+
background: rgba(157, 224, 255, 0.14);
|
| 571 |
+
color: var(--accent-strong);
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
.prompt-input {
|
| 575 |
+
width: 100%;
|
| 576 |
+
min-height: 78px;
|
| 577 |
+
padding: 14px 16px;
|
| 578 |
+
border: 1px solid var(--line);
|
| 579 |
+
border-radius: 20px;
|
| 580 |
+
background: rgba(255, 255, 255, 0.04);
|
| 581 |
+
color: var(--text);
|
| 582 |
+
line-height: 1.5;
|
| 583 |
+
resize: none;
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
.prompt-input::placeholder {
|
| 587 |
+
color: var(--text-muted);
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
.prompt-input:focus {
|
| 591 |
+
outline: 1px solid rgba(157, 224, 255, 0.44);
|
| 592 |
+
border-color: rgba(157, 224, 255, 0.44);
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
.capture-side-rail {
|
| 596 |
+
right: 24px;
|
| 597 |
+
width: min(400px, calc(100vw - 48px));
|
| 598 |
+
display: flex;
|
| 599 |
+
flex-direction: column;
|
| 600 |
+
gap: 14px;
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
.capture-actions {
|
| 604 |
+
position: fixed;
|
| 605 |
+
top: 24px;
|
| 606 |
+
right: 24px;
|
| 607 |
+
z-index: 4;
|
| 608 |
+
display: flex;
|
| 609 |
+
justify-content: flex-end;
|
| 610 |
+
gap: 10px;
|
| 611 |
+
flex-wrap: wrap;
|
| 612 |
+
pointer-events: auto;
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
.caption-dock {
|
| 616 |
+
width: 100%;
|
| 617 |
+
display: flex;
|
| 618 |
+
flex-direction: column;
|
| 619 |
+
align-items: stretch;
|
| 620 |
+
gap: 12px;
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
.caption-bubble {
|
| 624 |
+
width: 100%;
|
| 625 |
+
padding: 16px 18px;
|
| 626 |
+
border-radius: 30px 30px 12px 30px;
|
| 627 |
+
color: #07101b;
|
| 628 |
+
line-height: 1.48;
|
| 629 |
+
box-shadow: 0 20px 48px rgba(3, 7, 17, 0.24);
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
.caption-bubble--history {
|
| 633 |
+
background: rgba(255, 255, 255, 0.78);
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
.caption-bubble--active {
|
| 637 |
+
min-height: 74px;
|
| 638 |
+
background: rgba(255, 255, 255, 0.98);
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
.caption-meta {
|
| 642 |
+
margin-bottom: 8px;
|
| 643 |
+
color: rgba(7, 16, 27, 0.54);
|
| 644 |
+
font-family: var(--font-mono);
|
| 645 |
+
font-size: 0.72rem;
|
| 646 |
+
letter-spacing: 0.18em;
|
| 647 |
+
text-transform: uppercase;
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
.caption-placeholder {
|
| 651 |
+
color: rgba(7, 16, 27, 0.5);
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
@media (max-width: 900px) {
|
| 655 |
+
body {
|
| 656 |
+
overflow: auto;
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
.landing-scene,
|
| 660 |
+
.scene-shell {
|
| 661 |
+
padding: 22px;
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
.landing-inner {
|
| 665 |
+
min-height: calc(100vh - 44px);
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
.source-grid {
|
| 669 |
+
grid-template-columns: 1fr;
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
.capture-toolbar {
|
| 673 |
+
flex-direction: column;
|
| 674 |
+
align-items: flex-start;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
.prompt-dock,
|
| 678 |
+
.capture-side-rail {
|
| 679 |
+
position: relative;
|
| 680 |
+
left: auto;
|
| 681 |
+
right: auto;
|
| 682 |
+
bottom: auto;
|
| 683 |
+
width: auto;
|
| 684 |
+
margin: 0 22px 22px;
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
.capture-actions {
|
| 688 |
+
top: 22px;
|
| 689 |
+
right: 22px;
|
| 690 |
+
left: 22px;
|
| 691 |
+
justify-content: flex-start;
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
.capture-scene {
|
| 695 |
+
display: flex;
|
| 696 |
+
flex-direction: column;
|
| 697 |
+
justify-content: flex-end;
|
| 698 |
+
min-height: 100dvh;
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
.capture-scrim {
|
| 702 |
+
background: linear-gradient(
|
| 703 |
+
180deg,
|
| 704 |
+
rgba(3, 5, 10, 0.2),
|
| 705 |
+
rgba(3, 5, 10, 0.42) 42%,
|
| 706 |
+
rgba(3, 5, 10, 0.84)
|
| 707 |
+
),
|
| 708 |
+
radial-gradient(
|
| 709 |
+
circle at top left,
|
| 710 |
+
rgba(146, 220, 255, 0.18),
|
| 711 |
+
transparent 30%
|
| 712 |
+
);
|
| 713 |
+
}
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
@media (max-width: 640px) {
|
| 717 |
+
.hero-copy h1,
|
| 718 |
+
.loading-card h2,
|
| 719 |
+
.source-card h2 {
|
| 720 |
+
letter-spacing: -0.05em;
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
.floating-alert {
|
| 724 |
+
top: auto;
|
| 725 |
+
left: 22px;
|
| 726 |
+
right: 22px;
|
| 727 |
+
bottom: 22px;
|
| 728 |
+
flex-direction: column;
|
| 729 |
+
align-items: flex-start;
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
.floating-alert--secondary {
|
| 733 |
+
bottom: 96px;
|
| 734 |
+
}
|
| 735 |
+
}
|
src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from "react";
|
| 2 |
+
import { createRoot } from "react-dom/client";
|
| 3 |
+
import "./index.css";
|
| 4 |
+
import App from "./App.tsx";
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById("root")!).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
);
|
src/react-fluid-distortion/Fluid.tsx
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createPortal, useFrame, useThree } from "@react-three/fiber";
|
| 2 |
+
import { useCallback, useMemo, useRef, useState } from "react";
|
| 3 |
+
import { Camera, Color, Mesh, Scene, Texture, Vector2, Vector3 } from "three";
|
| 4 |
+
import { ShaderPass } from "three/examples/jsm/Addons.js";
|
| 5 |
+
import { Effect as FluidEffect } from "./effect/Fluid";
|
| 6 |
+
import { useFBOs } from "./hooks/useFBOs";
|
| 7 |
+
import { useMaterials } from "./hooks/useMaterials";
|
| 8 |
+
import { DEFAULT_CONFIG } from "./constant";
|
| 9 |
+
import { usePointer } from "./hooks/usePointer";
|
| 10 |
+
import { normalizeScreenHz } from "./utils";
|
| 11 |
+
import { type FluidProps } from "./types";
|
| 12 |
+
|
| 13 |
+
type MaterialName = keyof ReturnType<typeof useMaterials>;
|
| 14 |
+
type FBONames = keyof ReturnType<typeof useFBOs>;
|
| 15 |
+
|
| 16 |
+
type Uniforms = {
|
| 17 |
+
uColor: Vector3 | Color;
|
| 18 |
+
uPointer: Vector2;
|
| 19 |
+
uTarget: Texture | null;
|
| 20 |
+
uVelocity: Texture;
|
| 21 |
+
uCurl: Texture;
|
| 22 |
+
uTexture: Texture;
|
| 23 |
+
uPressure: Texture;
|
| 24 |
+
uDivergence: Texture;
|
| 25 |
+
uSource: Texture;
|
| 26 |
+
uRadius: number;
|
| 27 |
+
uClearValue: number;
|
| 28 |
+
uCurlValue: number;
|
| 29 |
+
uDissipation: number;
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
export const Fluid = ({
|
| 33 |
+
blend = DEFAULT_CONFIG.blend,
|
| 34 |
+
force = DEFAULT_CONFIG.force,
|
| 35 |
+
radius = DEFAULT_CONFIG.radius,
|
| 36 |
+
curl = DEFAULT_CONFIG.curl,
|
| 37 |
+
swirl = DEFAULT_CONFIG.swirl,
|
| 38 |
+
intensity = DEFAULT_CONFIG.intensity,
|
| 39 |
+
distortion = DEFAULT_CONFIG.distortion,
|
| 40 |
+
fluidColor = DEFAULT_CONFIG.fluidColor,
|
| 41 |
+
backgroundColor = DEFAULT_CONFIG.backgroundColor,
|
| 42 |
+
showBackground = DEFAULT_CONFIG.showBackground,
|
| 43 |
+
rainbow = DEFAULT_CONFIG.rainbow,
|
| 44 |
+
pressure = DEFAULT_CONFIG.pressure,
|
| 45 |
+
densityDissipation = DEFAULT_CONFIG.densityDissipation,
|
| 46 |
+
velocityDissipation = DEFAULT_CONFIG.velocityDissipation,
|
| 47 |
+
blendFunction = DEFAULT_CONFIG.blendFunction,
|
| 48 |
+
}: FluidProps) => {
|
| 49 |
+
const size = useThree((three) => three.size);
|
| 50 |
+
const gl = useThree((three) => three.gl);
|
| 51 |
+
|
| 52 |
+
const [bufferScene] = useState(() => new Scene());
|
| 53 |
+
const bufferCamera = useMemo(() => new Camera(), []);
|
| 54 |
+
|
| 55 |
+
const meshRef = useRef<Mesh>(null);
|
| 56 |
+
const postRef = useRef<ShaderPass>(null);
|
| 57 |
+
const pointerRef = useRef(new Vector2());
|
| 58 |
+
const colorRef = useRef(new Vector3());
|
| 59 |
+
|
| 60 |
+
const FBOs = useFBOs();
|
| 61 |
+
const materials = useMaterials();
|
| 62 |
+
const splatStack = usePointer({ force });
|
| 63 |
+
|
| 64 |
+
const setShaderMaterial = useCallback(
|
| 65 |
+
(name: MaterialName) => {
|
| 66 |
+
if (!meshRef.current) return;
|
| 67 |
+
|
| 68 |
+
meshRef.current.material = materials[name];
|
| 69 |
+
meshRef.current.material.needsUpdate = true;
|
| 70 |
+
},
|
| 71 |
+
[materials],
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
const setRenderTarget = useCallback(
|
| 75 |
+
(name: FBONames) => {
|
| 76 |
+
const target = FBOs[name];
|
| 77 |
+
|
| 78 |
+
if ("write" in target) {
|
| 79 |
+
gl.setRenderTarget(target.write);
|
| 80 |
+
gl.clear();
|
| 81 |
+
gl.render(bufferScene, bufferCamera);
|
| 82 |
+
target.swap();
|
| 83 |
+
} else {
|
| 84 |
+
gl.setRenderTarget(target);
|
| 85 |
+
gl.clear();
|
| 86 |
+
gl.render(bufferScene, bufferCamera);
|
| 87 |
+
}
|
| 88 |
+
},
|
| 89 |
+
[bufferCamera, bufferScene, FBOs, gl],
|
| 90 |
+
);
|
| 91 |
+
|
| 92 |
+
const setUniforms = useCallback(
|
| 93 |
+
<K extends keyof Uniforms>(
|
| 94 |
+
material: MaterialName,
|
| 95 |
+
uniform: K,
|
| 96 |
+
value: Uniforms[K],
|
| 97 |
+
) => {
|
| 98 |
+
const mat = materials[material];
|
| 99 |
+
|
| 100 |
+
if (mat && mat.uniforms[uniform]) {
|
| 101 |
+
mat.uniforms[uniform].value = value;
|
| 102 |
+
}
|
| 103 |
+
},
|
| 104 |
+
[materials],
|
| 105 |
+
);
|
| 106 |
+
|
| 107 |
+
useFrame((_, delta) => {
|
| 108 |
+
if (!meshRef.current || !postRef.current) return;
|
| 109 |
+
|
| 110 |
+
for (let i = splatStack.length - 1; i >= 0; i--) {
|
| 111 |
+
const { mouseX, mouseY, velocityX, velocityY } = splatStack[i];
|
| 112 |
+
|
| 113 |
+
pointerRef.current.set(mouseX, mouseY);
|
| 114 |
+
colorRef.current.set(velocityX, velocityY, 10.0);
|
| 115 |
+
|
| 116 |
+
setShaderMaterial("splat");
|
| 117 |
+
setUniforms("splat", "uTarget", FBOs.velocity.read.texture);
|
| 118 |
+
setUniforms("splat", "uPointer", pointerRef.current);
|
| 119 |
+
setUniforms("splat", "uColor", colorRef.current);
|
| 120 |
+
setUniforms("splat", "uRadius", radius / 100.0);
|
| 121 |
+
setRenderTarget("velocity");
|
| 122 |
+
setUniforms("splat", "uTarget", FBOs.density.read.texture);
|
| 123 |
+
setRenderTarget("density");
|
| 124 |
+
|
| 125 |
+
splatStack.pop();
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
setShaderMaterial("curl");
|
| 129 |
+
setUniforms("curl", "uVelocity", FBOs.velocity.read.texture);
|
| 130 |
+
setRenderTarget("curl");
|
| 131 |
+
|
| 132 |
+
setShaderMaterial("vorticity");
|
| 133 |
+
setUniforms("vorticity", "uVelocity", FBOs.velocity.read.texture);
|
| 134 |
+
setUniforms("vorticity", "uCurl", FBOs.curl.texture);
|
| 135 |
+
setUniforms("vorticity", "uCurlValue", curl);
|
| 136 |
+
setRenderTarget("velocity");
|
| 137 |
+
|
| 138 |
+
setShaderMaterial("divergence");
|
| 139 |
+
setUniforms("divergence", "uVelocity", FBOs.velocity.read.texture);
|
| 140 |
+
setRenderTarget("divergence");
|
| 141 |
+
|
| 142 |
+
setShaderMaterial("clear");
|
| 143 |
+
setUniforms("clear", "uTexture", FBOs.pressure.read.texture);
|
| 144 |
+
setUniforms("clear", "uClearValue", normalizeScreenHz(pressure, delta));
|
| 145 |
+
setRenderTarget("pressure");
|
| 146 |
+
|
| 147 |
+
setShaderMaterial("pressure");
|
| 148 |
+
setUniforms("pressure", "uDivergence", FBOs.divergence.texture);
|
| 149 |
+
|
| 150 |
+
for (let i = 0; i < swirl; i++) {
|
| 151 |
+
setUniforms("pressure", "uPressure", FBOs.pressure.read.texture);
|
| 152 |
+
setRenderTarget("pressure");
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
setShaderMaterial("gradientSubstract");
|
| 156 |
+
setUniforms("gradientSubstract", "uPressure", FBOs.pressure.read.texture);
|
| 157 |
+
setUniforms("gradientSubstract", "uVelocity", FBOs.velocity.read.texture);
|
| 158 |
+
setRenderTarget("velocity");
|
| 159 |
+
|
| 160 |
+
setShaderMaterial("advection");
|
| 161 |
+
setUniforms("advection", "uVelocity", FBOs.velocity.read.texture);
|
| 162 |
+
setUniforms("advection", "uSource", FBOs.velocity.read.texture);
|
| 163 |
+
setUniforms(
|
| 164 |
+
"advection",
|
| 165 |
+
"uDissipation",
|
| 166 |
+
normalizeScreenHz(velocityDissipation, delta),
|
| 167 |
+
);
|
| 168 |
+
|
| 169 |
+
setRenderTarget("velocity");
|
| 170 |
+
setUniforms("advection", "uVelocity", FBOs.velocity.read.texture);
|
| 171 |
+
setUniforms("advection", "uSource", FBOs.density.read.texture);
|
| 172 |
+
setUniforms(
|
| 173 |
+
"advection",
|
| 174 |
+
"uDissipation",
|
| 175 |
+
normalizeScreenHz(densityDissipation, delta),
|
| 176 |
+
);
|
| 177 |
+
|
| 178 |
+
setRenderTarget("density");
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
+
return (
|
| 182 |
+
<>
|
| 183 |
+
{createPortal(
|
| 184 |
+
<mesh ref={meshRef} scale={[size.width, size.height, 1]}>
|
| 185 |
+
<planeGeometry args={[2, 2]} />
|
| 186 |
+
</mesh>,
|
| 187 |
+
bufferScene,
|
| 188 |
+
)}
|
| 189 |
+
|
| 190 |
+
<FluidEffect
|
| 191 |
+
blendFunction={blendFunction}
|
| 192 |
+
intensity={intensity}
|
| 193 |
+
rainbow={rainbow}
|
| 194 |
+
distortion={distortion}
|
| 195 |
+
backgroundColor={backgroundColor}
|
| 196 |
+
blend={blend}
|
| 197 |
+
fluidColor={fluidColor}
|
| 198 |
+
showBackground={showBackground}
|
| 199 |
+
ref={postRef}
|
| 200 |
+
tFluid={FBOs.density.read.texture}
|
| 201 |
+
/>
|
| 202 |
+
</>
|
| 203 |
+
);
|
| 204 |
+
};
|
src/react-fluid-distortion/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 whatisjery
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
src/react-fluid-distortion/constant.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BlendFunction } from "postprocessing";
|
| 2 |
+
|
| 3 |
+
export const DEFAULT_CONFIG = {
|
| 4 |
+
blend: 5,
|
| 5 |
+
intensity: 2,
|
| 6 |
+
force: 1.1,
|
| 7 |
+
distortion: 0.4,
|
| 8 |
+
curl: 1.9,
|
| 9 |
+
radius: 0.3,
|
| 10 |
+
swirl: 4,
|
| 11 |
+
pressure: 0.8,
|
| 12 |
+
densityDissipation: 0.96,
|
| 13 |
+
velocityDissipation: 1.0,
|
| 14 |
+
fluidColor: "#3300ff",
|
| 15 |
+
backgroundColor: "#070410",
|
| 16 |
+
showBackground: true,
|
| 17 |
+
rainbow: false,
|
| 18 |
+
dyeRes: 512,
|
| 19 |
+
simRes: 128,
|
| 20 |
+
blendFunction: BlendFunction.SET,
|
| 21 |
+
} as const;
|
| 22 |
+
|
| 23 |
+
export const REFRESH_RATE = 60;
|
src/react-fluid-distortion/effect/Fluid.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { forwardRef, useEffect, useMemo } from "react";
|
| 2 |
+
import { FluidEffect } from "./FluidEffect";
|
| 3 |
+
import type { EffectProps } from "../types";
|
| 4 |
+
|
| 5 |
+
export const Effect = forwardRef(function Fluid(props: EffectProps, ref) {
|
| 6 |
+
// prevent re-creating the effect on every render
|
| 7 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 8 |
+
const effect = useMemo(() => new FluidEffect(props), []);
|
| 9 |
+
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
effect.state = { ...props };
|
| 12 |
+
effect.update();
|
| 13 |
+
}, [effect, props]);
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
return () => {
|
| 17 |
+
effect.dispose?.();
|
| 18 |
+
};
|
| 19 |
+
}, [effect]);
|
| 20 |
+
|
| 21 |
+
return <primitive ref={ref} object={effect} dispose={null} />;
|
| 22 |
+
});
|
src/react-fluid-distortion/effect/FluidEffect.tsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Effect, EffectAttribute } from "postprocessing";
|
| 2 |
+
import { Texture, Uniform, Vector3 } from "three";
|
| 3 |
+
import compositeFrag from "../glsl/composite.frag?raw";
|
| 4 |
+
import { hexToRgb } from "../utils";
|
| 5 |
+
import { type EffectProps } from "../types";
|
| 6 |
+
|
| 7 |
+
type Uniforms = {
|
| 8 |
+
tFluid: Texture;
|
| 9 |
+
uColor: Vector3;
|
| 10 |
+
uBackgroundColor: Vector3;
|
| 11 |
+
uRainbow: boolean;
|
| 12 |
+
uShowBackground: boolean;
|
| 13 |
+
uDistort: number;
|
| 14 |
+
uBlend: number;
|
| 15 |
+
uIntensity: number;
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export class FluidEffect extends Effect {
|
| 19 |
+
state: EffectProps;
|
| 20 |
+
|
| 21 |
+
constructor(props: EffectProps) {
|
| 22 |
+
const uniforms: Record<keyof Uniforms, Uniform> = {
|
| 23 |
+
tFluid: new Uniform(props.tFluid),
|
| 24 |
+
uDistort: new Uniform(props.distortion),
|
| 25 |
+
uRainbow: new Uniform(props.rainbow),
|
| 26 |
+
uIntensity: new Uniform(props.intensity),
|
| 27 |
+
uBlend: new Uniform(props.blend),
|
| 28 |
+
uShowBackground: new Uniform(props.showBackground),
|
| 29 |
+
uColor: new Uniform(hexToRgb(props.fluidColor!)),
|
| 30 |
+
uBackgroundColor: new Uniform(hexToRgb(props.backgroundColor!)),
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
super("FluidEffect", compositeFrag, {
|
| 34 |
+
blendFunction: props.blendFunction,
|
| 35 |
+
attributes: EffectAttribute.CONVOLUTION,
|
| 36 |
+
uniforms: new Map(Object.entries(uniforms)),
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
this.state = props;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
private updateUniform<K extends keyof Uniforms>(key: K, value: Uniforms[K]) {
|
| 43 |
+
const uniform = this.uniforms.get(key);
|
| 44 |
+
if (uniform) {
|
| 45 |
+
uniform.value = value;
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
update() {
|
| 50 |
+
this.updateUniform("uIntensity", this.state.intensity);
|
| 51 |
+
this.updateUniform("uDistort", this.state.distortion);
|
| 52 |
+
this.updateUniform("uRainbow", this.state.rainbow);
|
| 53 |
+
this.updateUniform("uBlend", this.state.blend);
|
| 54 |
+
this.updateUniform("uShowBackground", this.state.showBackground);
|
| 55 |
+
this.updateUniform("uColor", hexToRgb(this.state.fluidColor));
|
| 56 |
+
this.updateUniform(
|
| 57 |
+
"uBackgroundColor",
|
| 58 |
+
hexToRgb(this.state.backgroundColor),
|
| 59 |
+
);
|
| 60 |
+
}
|
| 61 |
+
}
|
src/react-fluid-distortion/glsl.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
declare module "*.glsl" {
|
| 2 |
+
const value: string;
|
| 3 |
+
export default value;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
declare module "*.vert" {
|
| 7 |
+
const value: string;
|
| 8 |
+
export default value;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
declare module "*.frag" {
|
| 12 |
+
const value: string;
|
| 13 |
+
export default value;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
declare module "*.vert?raw" {
|
| 17 |
+
const value: string;
|
| 18 |
+
export default value;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
declare module "*.frag?raw" {
|
| 22 |
+
const value: string;
|
| 23 |
+
export default value;
|
| 24 |
+
}
|
src/react-fluid-distortion/glsl/advection.frag
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
precision highp float;
|
| 2 |
+
|
| 3 |
+
varying vec2 vUv;
|
| 4 |
+
uniform sampler2D uVelocity;
|
| 5 |
+
uniform sampler2D uSource;
|
| 6 |
+
uniform vec2 texelSize;
|
| 7 |
+
uniform float dt;
|
| 8 |
+
uniform float uDissipation;
|
| 9 |
+
|
| 10 |
+
void main() {
|
| 11 |
+
vec2 coord = vUv - dt * texture2D(uVelocity, vUv).xy * texelSize;
|
| 12 |
+
|
| 13 |
+
gl_FragColor = uDissipation * texture2D(uSource, coord);
|
| 14 |
+
|
| 15 |
+
gl_FragColor.a = 1.0;
|
| 16 |
+
}
|
src/react-fluid-distortion/glsl/base.vert
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#ifdef USE_V_UV
|
| 2 |
+
varying vec2 vUv;
|
| 3 |
+
#endif
|
| 4 |
+
|
| 5 |
+
#ifdef USE_OFFSETS
|
| 6 |
+
varying vec2 vL;
|
| 7 |
+
varying vec2 vR;
|
| 8 |
+
varying vec2 vT;
|
| 9 |
+
varying vec2 vB;
|
| 10 |
+
uniform vec2 texelSize;
|
| 11 |
+
#endif
|
| 12 |
+
|
| 13 |
+
void main() {
|
| 14 |
+
#ifdef USE_V_UV
|
| 15 |
+
vUv = uv;
|
| 16 |
+
#endif
|
| 17 |
+
|
| 18 |
+
#ifdef USE_OFFSETS
|
| 19 |
+
vL = uv - vec2(texelSize.x, 0.0);
|
| 20 |
+
vR = uv + vec2(texelSize.x, 0.0);
|
| 21 |
+
vT = uv + vec2(0.0, texelSize.y);
|
| 22 |
+
vB = uv - vec2(0.0, texelSize.y);
|
| 23 |
+
#endif
|
| 24 |
+
|
| 25 |
+
gl_Position = vec4(position, 1.0);
|
| 26 |
+
}
|
src/react-fluid-distortion/glsl/clear.frag
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
precision highp float;
|
| 2 |
+
|
| 3 |
+
varying vec2 vUv;
|
| 4 |
+
uniform sampler2D uTexture;
|
| 5 |
+
uniform float uClearValue;
|
| 6 |
+
|
| 7 |
+
void main() { gl_FragColor = uClearValue * texture2D(uTexture, vUv); }
|
src/react-fluid-distortion/glsl/composite.frag
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
uniform sampler2D tFluid;
|
| 2 |
+
|
| 3 |
+
uniform vec3 uColor;
|
| 4 |
+
uniform vec3 uBackgroundColor;
|
| 5 |
+
|
| 6 |
+
uniform float uDistort;
|
| 7 |
+
uniform float uIntensity;
|
| 8 |
+
uniform float uRainbow;
|
| 9 |
+
uniform float uBlend;
|
| 10 |
+
uniform float uShowBackground;
|
| 11 |
+
|
| 12 |
+
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
|
| 13 |
+
|
| 14 |
+
vec3 fluidColor = texture2D(tFluid, uv).rgb;
|
| 15 |
+
|
| 16 |
+
vec2 distortedUv = uv - fluidColor.rg * uDistort * 0.001;
|
| 17 |
+
|
| 18 |
+
vec4 texture = texture2D(inputBuffer, distortedUv);
|
| 19 |
+
|
| 20 |
+
float intensity = length(fluidColor) * uIntensity * 0.0001;
|
| 21 |
+
|
| 22 |
+
vec3 selectedColor = uColor * length(fluidColor);
|
| 23 |
+
|
| 24 |
+
vec4 colorForFluidEffect = vec4(uRainbow == 1.0 ? fluidColor : selectedColor, 1.0);
|
| 25 |
+
|
| 26 |
+
vec4 computedBgColor = uShowBackground != 0.0 ? vec4(uBackgroundColor, 1.0) : vec4(0.0, 0.0, 0.0, 0.0);
|
| 27 |
+
|
| 28 |
+
outputColor = mix(texture, colorForFluidEffect, intensity);
|
| 29 |
+
|
| 30 |
+
vec4 computedFluidColor = mix(texture, colorForFluidEffect, uBlend * 0.01);
|
| 31 |
+
|
| 32 |
+
vec4 finalColor;
|
| 33 |
+
|
| 34 |
+
if(texture.a < 0.1) {
|
| 35 |
+
finalColor = mix(computedBgColor, colorForFluidEffect, intensity);
|
| 36 |
+
} else {
|
| 37 |
+
finalColor = mix(computedFluidColor, computedBgColor, 1.0 - texture.a);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
outputColor = finalColor;
|
| 41 |
+
}
|
src/react-fluid-distortion/glsl/curl.frag
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
precision highp float;
|
| 2 |
+
|
| 3 |
+
varying vec2 vL;
|
| 4 |
+
varying vec2 vR;
|
| 5 |
+
varying vec2 vT;
|
| 6 |
+
varying vec2 vB;
|
| 7 |
+
|
| 8 |
+
uniform sampler2D uVelocity;
|
| 9 |
+
|
| 10 |
+
void main() {
|
| 11 |
+
float L = texture2D(uVelocity, vL).y;
|
| 12 |
+
|
| 13 |
+
float R = texture2D(uVelocity, vR).y;
|
| 14 |
+
|
| 15 |
+
float T = texture2D(uVelocity, vT).x;
|
| 16 |
+
|
| 17 |
+
float B = texture2D(uVelocity, vB).x;
|
| 18 |
+
|
| 19 |
+
float vorticity = R - L - T + B;
|
| 20 |
+
|
| 21 |
+
gl_FragColor = vec4(vorticity, 0.0, 0.0, 1.0);
|
| 22 |
+
}
|
src/react-fluid-distortion/glsl/divergence.frag
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
precision highp float;
|
| 2 |
+
|
| 3 |
+
varying highp vec2 vUv;
|
| 4 |
+
varying highp vec2 vL;
|
| 5 |
+
varying highp vec2 vR;
|
| 6 |
+
varying highp vec2 vT;
|
| 7 |
+
varying highp vec2 vB;
|
| 8 |
+
|
| 9 |
+
uniform sampler2D uVelocity;
|
| 10 |
+
|
| 11 |
+
void main() {
|
| 12 |
+
float L = texture2D(uVelocity, vL).x;
|
| 13 |
+
|
| 14 |
+
float R = texture2D(uVelocity, vR).x;
|
| 15 |
+
|
| 16 |
+
float T = texture2D(uVelocity, vT).y;
|
| 17 |
+
|
| 18 |
+
float B = texture2D(uVelocity, vB).y;
|
| 19 |
+
|
| 20 |
+
vec2 C = texture2D(uVelocity, vUv).xy;
|
| 21 |
+
|
| 22 |
+
if(vL.x < 0.0) {
|
| 23 |
+
L = -C.x;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
if(vR.x > 1.0) {
|
| 27 |
+
R = -C.x;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
if(vT.y > 1.0) {
|
| 31 |
+
T = -C.y;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
if(vB.y < 0.0) {
|
| 35 |
+
B = -C.y;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
float div = 0.5 * (R - L + T - B);
|
| 39 |
+
|
| 40 |
+
gl_FragColor = vec4(div, 0.0, 0.0, 1.0);
|
| 41 |
+
}
|
src/react-fluid-distortion/glsl/gradientSubstract.frag
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
precision highp float;
|
| 2 |
+
|
| 3 |
+
varying highp vec2 vUv;
|
| 4 |
+
varying highp vec2 vL;
|
| 5 |
+
varying highp vec2 vR;
|
| 6 |
+
varying highp vec2 vT;
|
| 7 |
+
varying highp vec2 vB;
|
| 8 |
+
|
| 9 |
+
uniform sampler2D uPressure;
|
| 10 |
+
uniform sampler2D uVelocity;
|
| 11 |
+
|
| 12 |
+
void main() {
|
| 13 |
+
float L = texture2D(uPressure, vL).x;
|
| 14 |
+
|
| 15 |
+
float R = texture2D(uPressure, vR).x;
|
| 16 |
+
|
| 17 |
+
float T = texture2D(uPressure, vT).x;
|
| 18 |
+
|
| 19 |
+
float B = texture2D(uPressure, vB).x;
|
| 20 |
+
|
| 21 |
+
vec2 velocity = texture2D(uVelocity, vUv).xy;
|
| 22 |
+
|
| 23 |
+
velocity.xy -= vec2(R - L, T - B);
|
| 24 |
+
|
| 25 |
+
gl_FragColor = vec4(velocity, 0.0, 1.0);
|
| 26 |
+
}
|
src/react-fluid-distortion/glsl/pressure.frag
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
precision highp float;
|
| 2 |
+
|
| 3 |
+
varying highp vec2 vUv;
|
| 4 |
+
varying highp vec2 vL;
|
| 5 |
+
varying highp vec2 vR;
|
| 6 |
+
varying highp vec2 vT;
|
| 7 |
+
varying highp vec2 vB;
|
| 8 |
+
|
| 9 |
+
uniform sampler2D uPressure;
|
| 10 |
+
uniform sampler2D uDivergence;
|
| 11 |
+
|
| 12 |
+
void main() {
|
| 13 |
+
float L = texture2D(uPressure, vL).x;
|
| 14 |
+
|
| 15 |
+
float R = texture2D(uPressure, vR).x;
|
| 16 |
+
|
| 17 |
+
float T = texture2D(uPressure, vT).x;
|
| 18 |
+
|
| 19 |
+
float B = texture2D(uPressure, vB).x;
|
| 20 |
+
|
| 21 |
+
float C = texture2D(uPressure, vUv).x;
|
| 22 |
+
|
| 23 |
+
float divergence = texture2D(uDivergence, vUv).x;
|
| 24 |
+
|
| 25 |
+
float pressure = (L + R + B + T - divergence) * 0.25;
|
| 26 |
+
|
| 27 |
+
gl_FragColor = vec4(pressure, 0.0, 0.0, 1.0);
|
| 28 |
+
}
|
src/react-fluid-distortion/glsl/splat.frag
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
varying vec2 vUv;
|
| 2 |
+
|
| 3 |
+
uniform sampler2D uTarget;
|
| 4 |
+
uniform float aspectRatio;
|
| 5 |
+
uniform vec3 uColor;
|
| 6 |
+
uniform vec2 uPointer;
|
| 7 |
+
uniform float uRadius;
|
| 8 |
+
|
| 9 |
+
void main() {
|
| 10 |
+
vec2 p = vUv - uPointer.xy;
|
| 11 |
+
|
| 12 |
+
p.x *= aspectRatio;
|
| 13 |
+
|
| 14 |
+
vec3 splat = exp(-dot(p, p) / uRadius) * uColor;
|
| 15 |
+
|
| 16 |
+
vec3 base = texture2D(uTarget, vUv).xyz;
|
| 17 |
+
|
| 18 |
+
gl_FragColor = vec4(base + splat, 1.0);
|
| 19 |
+
}
|
src/react-fluid-distortion/glsl/vorticity.frag
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
precision highp float;
|
| 2 |
+
|
| 3 |
+
varying vec2 vUv;
|
| 4 |
+
varying vec2 vL;
|
| 5 |
+
varying vec2 vR;
|
| 6 |
+
varying vec2 vT;
|
| 7 |
+
varying vec2 vB;
|
| 8 |
+
|
| 9 |
+
uniform sampler2D uVelocity;
|
| 10 |
+
uniform sampler2D uCurl;
|
| 11 |
+
uniform float uCurlValue;
|
| 12 |
+
uniform float dt;
|
| 13 |
+
|
| 14 |
+
void main() {
|
| 15 |
+
float L = texture2D(uCurl, vL).x;
|
| 16 |
+
|
| 17 |
+
float R = texture2D(uCurl, vR).x;
|
| 18 |
+
|
| 19 |
+
float T = texture2D(uCurl, vT).x;
|
| 20 |
+
|
| 21 |
+
float B = texture2D(uCurl, vB).x;
|
| 22 |
+
|
| 23 |
+
float C = texture2D(uCurl, vUv).x;
|
| 24 |
+
|
| 25 |
+
vec2 force = vec2(abs(T) - abs(B), abs(R) - abs(L)) * 0.5;
|
| 26 |
+
|
| 27 |
+
force /= length(force) + 1.;
|
| 28 |
+
|
| 29 |
+
force *= uCurlValue * C;
|
| 30 |
+
|
| 31 |
+
force.y *= -1.;
|
| 32 |
+
|
| 33 |
+
vec2 vel = texture2D(uVelocity, vUv).xy;
|
| 34 |
+
|
| 35 |
+
gl_FragColor = vec4(vel + force * dt, 0.0, 1.0);
|
| 36 |
+
}
|
src/react-fluid-distortion/hooks/useDoubleFBO.tsx
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as THREE from "three";
|
| 2 |
+
import { useFBO, type FboProps } from "@react-three/drei";
|
| 3 |
+
import { useRef } from "react";
|
| 4 |
+
|
| 5 |
+
type FBO = {
|
| 6 |
+
read: THREE.WebGLRenderTarget;
|
| 7 |
+
write: THREE.WebGLRenderTarget;
|
| 8 |
+
swap: () => void;
|
| 9 |
+
dispose: () => void;
|
| 10 |
+
setGenerateMipmaps: (value: boolean) => void;
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
export const useDoubleFBO = (
|
| 14 |
+
width: number,
|
| 15 |
+
height: number,
|
| 16 |
+
options: FboProps,
|
| 17 |
+
) => {
|
| 18 |
+
const read = useFBO(width, height, options);
|
| 19 |
+
|
| 20 |
+
const write = useFBO(width, height, options);
|
| 21 |
+
|
| 22 |
+
const fbo = useRef<FBO>({
|
| 23 |
+
read,
|
| 24 |
+
write,
|
| 25 |
+
swap: () => {
|
| 26 |
+
const temp = fbo.read;
|
| 27 |
+
fbo.read = fbo.write;
|
| 28 |
+
fbo.write = temp;
|
| 29 |
+
},
|
| 30 |
+
dispose: () => {
|
| 31 |
+
read.dispose();
|
| 32 |
+
write.dispose();
|
| 33 |
+
},
|
| 34 |
+
setGenerateMipmaps: (value: boolean) => {
|
| 35 |
+
read.texture.generateMipmaps = value;
|
| 36 |
+
write.texture.generateMipmaps = value;
|
| 37 |
+
},
|
| 38 |
+
}).current;
|
| 39 |
+
|
| 40 |
+
return fbo;
|
| 41 |
+
};
|