Spaces:
Running
Running
Commit ·
576d07a
0
Parent(s):
Gemma 4 multimodal WebGPU detection Space
Browse files- .gitattributes +36 -0
- .gitignore +4 -0
- README.md +22 -0
- dist/assets/index-DLpffQqr.css +2 -0
- dist/assets/index-WzmSvmGf.js +0 -0
- dist/assets/ort-wasm-simd-threaded.asyncify-CtKKja6V.wasm +3 -0
- dist/favicon.svg +12 -0
- dist/gemma4.png +3 -0
- dist/index.html +17 -0
- dist/liquid-dark.webp +0 -0
- index.html +16 -0
- package-lock.json +0 -0
- package.json +30 -0
- public/favicon.svg +12 -0
- public/gemma4.png +3 -0
- public/liquid-dark.webp +0 -0
- src/App.tsx +37 -0
- src/components/DetectionApp.tsx +160 -0
- src/components/DetectionOverlay.tsx +126 -0
- src/components/ExampleGallery.tsx +37 -0
- src/components/GemmaIntro.tsx +96 -0
- src/components/ImageUpload.tsx +123 -0
- src/components/LoadingScreen.tsx +73 -0
- src/components/ResultPanel.tsx +58 -0
- src/hooks/LLMContext.ts +30 -0
- src/hooks/LLMProvider.tsx +207 -0
- src/hooks/useLLM.ts +8 -0
- src/index.css +93 -0
- src/main.tsx +13 -0
- src/utils/coco-examples.ts +48 -0
- src/utils/detection-parser.ts +68 -0
- src/utils/gemma-shader.ts +361 -0
- src/utils/image-utils.ts +59 -0
- src/utils/liquid1.min.d.ts +17 -0
- src/utils/liquid1.min.js +0 -0
- src/vite-env.d.ts +2 -0
- tsconfig.app.json +21 -0
- tsconfig.json +7 -0
- tsconfig.node.json +19 -0
- vite.config.ts +7 -0
.gitattributes
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
*.tsbuildinfo
|
| 3 |
+
.claude/
|
| 4 |
+
CLAUDE.md
|
README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Gemma 4 WebGPU
|
| 3 |
+
emoji: 💎
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: static
|
| 7 |
+
pinned: false
|
| 8 |
+
app_file: dist/index.html
|
| 9 |
+
short_description: Gemma 4 multimodal detection in your browser
|
| 10 |
+
models:
|
| 11 |
+
- onnx-community/gemma-4-E2B-it-ONNX
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# Gemma 4 Multimodal WebGPU
|
| 15 |
+
|
| 16 |
+
Run [Gemma 4 E2B](https://huggingface.co/onnx-community/gemma-4-E2B-it-ONNX) directly in your browser with WebGPU acceleration via [Transformers.js](https://github.com/huggingface/transformers.js).
|
| 17 |
+
|
| 18 |
+
Upload an image or pick a COCO example, and Gemma 4 will detect and describe objects with bounding box overlays.
|
| 19 |
+
|
| 20 |
+
- **100% Private**: All inference runs locally in your browser
|
| 21 |
+
- **No Server**: Static Space, no backend required
|
| 22 |
+
- **WebGPU**: Hardware-accelerated multimodal AI
|
dist/assets/index-DLpffQqr.css
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
|
| 2 |
+
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-6xl:3.75rem;--text-6xl--line-height:1;--text-7xl:4.5rem;--text-7xl--line-height:1;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html,body{color:#fff;background:#0a0a0a;height:100%;font-family:Google Sans,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;font-size:16px;overflow:hidden}#root{height:100%}*{scrollbar-width:thin;scrollbar-color:transparent transparent}:hover{scrollbar-color:#ffffff26 transparent}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:0 0;border-radius:3px}:hover::-webkit-scrollbar-thumb{background:#fff3}:hover::-webkit-scrollbar-thumb:hover{background:#ffffff59}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-0{inset:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.top-2{top:calc(var(--spacing) * 2)}.right-2{right:calc(var(--spacing) * 2)}.left-0{left:calc(var(--spacing) * 0)}.z-30{z-index:30}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.aspect-square{aspect-ratio:1}.h-1{height:calc(var(--spacing) * 1)}.h-2{height:calc(var(--spacing) * 2)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-16{height:calc(var(--spacing) * 16)}.h-full{height:100%}.h-screen{height:100vh}.max-h-\[200px\]{max-height:200px}.max-h-\[400px\]{max-height:400px}.max-h-\[500px\]{max-height:500px}.w-2{width:calc(var(--spacing) * 2)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-16{width:calc(var(--spacing) * 16)}.w-full{width:100%}.w-screen{width:100vw}.max-w-4xl{max-width:var(--container-4xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.animate-spin{animation:var(--animate-spin)}.cursor-pointer{cursor:pointer}.resize{resize:both}.resize-none{resize:none}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-\[\#1a73e8\]{border-color:#1a73e8}.border-\[\#1a73e8\]\/40{border-color:oklab(57.3697% -.0409326 -.190249/.4)}.border-amber-500\/40{border-color:#f99c0066}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/40{border-color:color-mix(in oklab, var(--color-amber-500) 40%, transparent)}}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-\[\#0a0a0a\]{background-color:#0a0a0a}.bg-\[\#1a73e8\]{background-color:#1a73e8}.bg-\[\#1a73e8\]\/10{background-color:oklab(57.3697% -.0409326 -.190249/.1)}.bg-\[\#1a73e8\]\/15{background-color:oklab(57.3697% -.0409326 -.190249/.15)}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-amber-500\/20{background-color:#f99c0033}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/20{background-color:color-mix(in oklab, var(--color-amber-500) 20%, transparent)}}.bg-black{background-color:var(--color-black)}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab, var(--color-black) 40%, transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/10{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}.bg-white\/5{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.bg-white\/5{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-gradient-to-t{--tw-gradient-position:to top in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-black\/70{--tw-gradient-from:#000000b3}@supports (color:color-mix(in lab, red, red)){.from-black\/70{--tw-gradient-from:color-mix(in oklab, var(--color-black) 70%, transparent)}}.from-black\/70{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-transparent{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-10{padding:calc(var(--spacing) * 10)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.pb-10{padding-bottom:calc(var(--spacing) * 10)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#1a73e8\]{color:#1a73e8}.text-\[\#8bb4f0\]{color:#8bb4f0}.text-amber-200{color:var(--color-amber-200)}.text-amber-200\/70{color:#fee685b3}@supports (color:color-mix(in lab, red, red)){.text-amber-200\/70{color:color-mix(in oklab, var(--color-amber-200) 70%, transparent)}}.text-amber-400{color:var(--color-amber-400)}.text-red-400{color:var(--color-red-400)}.text-white{color:var(--color-white)}.text-white\/40{color:#fff6}@supports (color:color-mix(in lab, red, red)){.text-white\/40{color:color-mix(in oklab, var(--color-white) 40%, transparent)}}.text-white\/50{color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.text-white\/50{color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.text-white\/60{color:#fff9}@supports (color:color-mix(in lab, red, red)){.text-white\/60{color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.text-white\/70{color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.text-white\/70{color:color-mix(in oklab, var(--color-white) 70%, transparent)}}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab, red, red)){.text-white\/80{color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.text-white\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-white\/90{color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.capitalize{text-transform:capitalize}.placeholder-white\/30::placeholder{color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.placeholder-white\/30::placeholder{color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow\!{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow-\[0_1px_6px_rgba\(0\,0\,0\,0\.5\)\]{--tw-drop-shadow-size:drop-shadow(0 1px 6px var(--tw-drop-shadow-color,#00000080));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow-\[0_2px_12px_rgba\(0\,0\,0\,0\.5\)\]{--tw-drop-shadow-size:drop-shadow(0 2px 12px var(--tw-drop-shadow-color,#00000080));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.duration-600{--tw-duration:.6s;transition-duration:.6s}.duration-700{--tw-duration:.7s;transition-duration:.7s}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:scale-110:is(:where(.group):hover *){--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.hover\:border-\[\#1a73e8\]\/60:hover{border-color:oklab(57.3697% -.0409326 -.190249/.6)}.hover\:border-white\/30:hover{border-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.hover\:border-white\/30:hover{border-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.hover\:bg-red-500\/20:hover{background-color:#fb2c3633}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-500\/20:hover{background-color:color-mix(in oklab, var(--color-red-500) 20%, transparent)}}.hover\:bg-red-500\/80:hover{background-color:#fb2c36cc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-500\/80:hover{background-color:color-mix(in oklab, var(--color-red-500) 80%, transparent)}}.hover\:bg-white\/5:hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/5:hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.hover\:text-white:hover{color:var(--color-white)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:shadow-\[\#1a73e8\]\/20:hover{--tw-shadow-color:#1a73e833}@supports (color:color-mix(in lab, red, red)){.hover\:shadow-\[\#1a73e8\]\/20:hover{--tw-shadow-color:color-mix(in oklab, oklab(57.3697% -.0409326 -.190249/.2) var(--tw-shadow-alpha), transparent)}}}.focus\:border-\[\#1a73e8\]:focus{border-color:#1a73e8}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-\[\#1a73e8\]:focus{--tw-ring-color:#1a73e8}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:opacity-50:disabled{opacity:.5}@media (width>=40rem){.sm\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:text-7xl{font-size:var(--text-7xl);line-height:var(--tw-leading,var(--text-7xl--line-height))}}}@keyframes pulse-gentle{0%,to{opacity:.6}50%{opacity:1}}.animate-pulse-gentle{animation:2.5s ease-in-out infinite pulse-gentle}@keyframes fade-in{0%{opacity:0}to{opacity:1}}.animate-fade-in{animation:.5s ease-out both fade-in}@keyframes shimmer-detection{0%{background-position:200% 0}to{background-position:-200% 0}}.detection-shimmer{-webkit-text-fill-color:transparent;background:linear-gradient(105deg,#8c8c94 0% 40%,#1a73e8 50%,#8c8c94 60% 100%) 0 0/200% 100%;-webkit-background-clip:text;background-clip:text;animation:2s ease-in-out infinite shimmer-detection}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"<length-percentage>";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"<length-percentage>";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"<length-percentage>";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}
|
dist/assets/index-WzmSvmGf.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/ort-wasm-simd-threaded.asyncify-CtKKja6V.wasm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1d6ee4ff60d7f0e6b6efa34469157d3bdcbe2f3b0dbcea2a645bb41361a85973
|
| 3 |
+
size 23543806
|
dist/favicon.svg
ADDED
|
|
dist/gemma4.png
ADDED
|
Git LFS Details
|
dist/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&display=swap" rel="stylesheet" />
|
| 10 |
+
<title>Gemma 4 | In-Browser Multimodal Detection</title>
|
| 11 |
+
<script type="module" crossorigin src="/assets/index-WzmSvmGf.js"></script>
|
| 12 |
+
<link rel="stylesheet" crossorigin href="/assets/index-DLpffQqr.css">
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<div id="root"></div>
|
| 16 |
+
</body>
|
| 17 |
+
</html>
|
dist/liquid-dark.webp
ADDED
|
index.html
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&display=swap" rel="stylesheet" />
|
| 10 |
+
<title>Gemma 4 | In-Browser Multimodal Detection</title>
|
| 11 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<div id="root"></div>
|
| 15 |
+
</body>
|
| 16 |
+
</html>
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "gemma4-webgpu",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@huggingface/transformers": "^4.0.0-next.3",
|
| 13 |
+
"@tailwindcss/vite": "^4.1.18",
|
| 14 |
+
"lucide-react": "^0.563.0",
|
| 15 |
+
"react": "^19.2.0",
|
| 16 |
+
"react-dom": "^19.2.0",
|
| 17 |
+
"tailwindcss": "^4.1.18"
|
| 18 |
+
},
|
| 19 |
+
"devDependencies": {
|
| 20 |
+
"@types/react": "^19.2.7",
|
| 21 |
+
"@types/react-dom": "^19.2.3",
|
| 22 |
+
"@vitejs/plugin-react": "^5.1.1",
|
| 23 |
+
"@webgpu/types": "^0.1.69",
|
| 24 |
+
"typescript": "~5.9.3",
|
| 25 |
+
"vite": "^8.0.0-beta.13"
|
| 26 |
+
},
|
| 27 |
+
"overrides": {
|
| 28 |
+
"vite": "^8.0.0-beta.13"
|
| 29 |
+
}
|
| 30 |
+
}
|
public/favicon.svg
ADDED
|
|
public/gemma4.png
ADDED
|
Git LFS Details
|
public/liquid-dark.webp
ADDED
|
src/App.tsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from "react";
|
| 2 |
+
import { GemmaIntro } from "./components/GemmaIntro";
|
| 3 |
+
import { LoadingScreen } from "./components/LoadingScreen";
|
| 4 |
+
import { DetectionApp } from "./components/DetectionApp";
|
| 5 |
+
import { useLLM } from "./hooks/useLLM";
|
| 6 |
+
|
| 7 |
+
function App() {
|
| 8 |
+
const { status, loadModel } = useLLM();
|
| 9 |
+
|
| 10 |
+
const [stage, setStage] = useState<"intro" | "loading" | "app">("intro");
|
| 11 |
+
|
| 12 |
+
const isReady = status.state === "ready";
|
| 13 |
+
|
| 14 |
+
// Move from loading → app once ready
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
if (stage === "loading" && isReady) {
|
| 17 |
+
setStage("app");
|
| 18 |
+
}
|
| 19 |
+
}, [stage, isReady]);
|
| 20 |
+
|
| 21 |
+
const handleEnterFromIntro = () => {
|
| 22 |
+
setStage("loading");
|
| 23 |
+
loadModel();
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<div className="relative h-screen w-screen bg-[#0a0a0a] text-white">
|
| 28 |
+
{stage === "intro" && <GemmaIntro onEnter={handleEnterFromIntro} />}
|
| 29 |
+
|
| 30 |
+
{stage === "loading" && <LoadingScreen status={status} />}
|
| 31 |
+
|
| 32 |
+
{stage === "app" && <DetectionApp />}
|
| 33 |
+
</div>
|
| 34 |
+
);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export default App;
|
src/components/DetectionApp.tsx
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from "react";
|
| 2 |
+
import { RotateCcw, AlertTriangle } from "lucide-react";
|
| 3 |
+
import { useLLM } from "../hooks/useLLM";
|
| 4 |
+
import { ImageUpload } from "./ImageUpload";
|
| 5 |
+
import { ExampleGallery } from "./ExampleGallery";
|
| 6 |
+
import { DetectionOverlay } from "./DetectionOverlay";
|
| 7 |
+
import { ResultPanel } from "./ResultPanel";
|
| 8 |
+
import { urlToDataUrl } from "../utils/image-utils";
|
| 9 |
+
import { DEFAULT_DETECTION_PROMPT } from "../utils/coco-examples";
|
| 10 |
+
|
| 11 |
+
export function DetectionApp() {
|
| 12 |
+
const { detect, isGenerating, tps, dtype, result, stop } = useLLM();
|
| 13 |
+
const [currentImage, setCurrentImage] = useState<string | null>(null);
|
| 14 |
+
const [prompt, setPrompt] = useState(DEFAULT_DETECTION_PROMPT);
|
| 15 |
+
const [loading, setLoading] = useState(false);
|
| 16 |
+
|
| 17 |
+
const handleImageSelect = useCallback(
|
| 18 |
+
(dataUrl: string) => {
|
| 19 |
+
if (!dataUrl) {
|
| 20 |
+
setCurrentImage(null);
|
| 21 |
+
return;
|
| 22 |
+
}
|
| 23 |
+
setCurrentImage(dataUrl);
|
| 24 |
+
detect(dataUrl, prompt);
|
| 25 |
+
},
|
| 26 |
+
[detect, prompt],
|
| 27 |
+
);
|
| 28 |
+
|
| 29 |
+
const handleExampleSelect = useCallback(
|
| 30 |
+
async (url: string, examplePrompt: string) => {
|
| 31 |
+
setLoading(true);
|
| 32 |
+
try {
|
| 33 |
+
const dataUrl = await urlToDataUrl(url);
|
| 34 |
+
setCurrentImage(dataUrl);
|
| 35 |
+
setPrompt(examplePrompt);
|
| 36 |
+
detect(dataUrl, examplePrompt);
|
| 37 |
+
} catch (err) {
|
| 38 |
+
console.error("[Gemma4] Failed to load example:", err);
|
| 39 |
+
alert(`Failed to load example image (likely CORS):\n${err instanceof Error ? err.message : String(err)}`);
|
| 40 |
+
}
|
| 41 |
+
setLoading(false);
|
| 42 |
+
},
|
| 43 |
+
[detect],
|
| 44 |
+
);
|
| 45 |
+
|
| 46 |
+
const handleRetry = useCallback(() => {
|
| 47 |
+
if (currentImage) {
|
| 48 |
+
detect(currentImage, prompt);
|
| 49 |
+
}
|
| 50 |
+
}, [currentImage, prompt, detect]);
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div className="flex flex-col h-full bg-[#0a0a0a] text-white animate-fade-in">
|
| 54 |
+
{/* Header */}
|
| 55 |
+
<header className="flex items-center justify-between px-4 py-3 sm:px-6 border-b border-white/10">
|
| 56 |
+
<span className="text-sm font-medium tracking-tight">Gemma 4</span>
|
| 57 |
+
|
| 58 |
+
<div className="flex items-center gap-2">
|
| 59 |
+
{isGenerating && (
|
| 60 |
+
<button
|
| 61 |
+
onClick={stop}
|
| 62 |
+
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer"
|
| 63 |
+
>
|
| 64 |
+
Stop
|
| 65 |
+
</button>
|
| 66 |
+
)}
|
| 67 |
+
{currentImage && !isGenerating && (
|
| 68 |
+
<button
|
| 69 |
+
onClick={handleRetry}
|
| 70 |
+
className="p-1.5 rounded-lg hover:bg-white/5 transition-colors text-white/60 hover:text-white cursor-pointer"
|
| 71 |
+
title="Retry detection"
|
| 72 |
+
>
|
| 73 |
+
<RotateCcw className="h-4 w-4" />
|
| 74 |
+
</button>
|
| 75 |
+
)}
|
| 76 |
+
</div>
|
| 77 |
+
</header>
|
| 78 |
+
|
| 79 |
+
{/* Main content */}
|
| 80 |
+
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
|
| 81 |
+
<div className="max-w-4xl mx-auto space-y-6">
|
| 82 |
+
{/* q4 slow-path warning */}
|
| 83 |
+
{dtype === "q4" && (
|
| 84 |
+
<div className="flex items-start gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-200">
|
| 85 |
+
<AlertTriangle className="h-5 w-5 shrink-0 mt-0.5 text-amber-400" />
|
| 86 |
+
<div>
|
| 87 |
+
<p className="font-medium">Running on q4 (slow path) — inference may fail with OOM on 8GB GPUs.</p>
|
| 88 |
+
<p className="mt-1 text-amber-200/70 text-xs leading-relaxed">
|
| 89 |
+
For faster, stabler inference, enable{" "}
|
| 90 |
+
<code className="font-mono bg-amber-500/20 px-1 rounded">chrome://flags/#enable-unsafe-webgpu</code> and{" "}
|
| 91 |
+
<code className="font-mono bg-amber-500/20 px-1 rounded">chrome://flags/#enable-vulkan</code>, then fully restart Chrome.
|
| 92 |
+
</p>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
)}
|
| 96 |
+
|
| 97 |
+
{/* Prompt input */}
|
| 98 |
+
<div>
|
| 99 |
+
<label className="block text-sm font-medium text-white/60 mb-2">
|
| 100 |
+
Prompt
|
| 101 |
+
</label>
|
| 102 |
+
<textarea
|
| 103 |
+
value={prompt}
|
| 104 |
+
onChange={(e) => setPrompt(e.target.value)}
|
| 105 |
+
rows={2}
|
| 106 |
+
disabled={isGenerating}
|
| 107 |
+
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white placeholder-white/30 focus:border-[#1a73e8] focus:outline-none focus:ring-1 focus:ring-[#1a73e8] resize-none disabled:opacity-50"
|
| 108 |
+
placeholder="Enter your prompt..."
|
| 109 |
+
/>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
{/* Image area */}
|
| 113 |
+
{currentImage ? (
|
| 114 |
+
<div className="space-y-4">
|
| 115 |
+
{result && result.detections.length > 0 ? (
|
| 116 |
+
<DetectionOverlay
|
| 117 |
+
imageUrl={currentImage}
|
| 118 |
+
detections={result.detections}
|
| 119 |
+
/>
|
| 120 |
+
) : (
|
| 121 |
+
<img
|
| 122 |
+
src={currentImage}
|
| 123 |
+
alt="Uploaded"
|
| 124 |
+
className="w-full max-h-[500px] object-contain rounded-xl border border-white/10"
|
| 125 |
+
/>
|
| 126 |
+
)}
|
| 127 |
+
|
| 128 |
+
<ResultPanel
|
| 129 |
+
result={result}
|
| 130 |
+
isGenerating={isGenerating}
|
| 131 |
+
tps={tps}
|
| 132 |
+
/>
|
| 133 |
+
|
| 134 |
+
{!isGenerating && (
|
| 135 |
+
<button
|
| 136 |
+
onClick={() => setCurrentImage(null)}
|
| 137 |
+
className="w-full py-3 rounded-xl border border-white/10 text-sm font-medium text-white/60 hover:text-white hover:border-white/30 transition-colors cursor-pointer"
|
| 138 |
+
>
|
| 139 |
+
Try another image
|
| 140 |
+
</button>
|
| 141 |
+
)}
|
| 142 |
+
</div>
|
| 143 |
+
) : (
|
| 144 |
+
<div className="space-y-6">
|
| 145 |
+
<ImageUpload
|
| 146 |
+
onImageSelect={handleImageSelect}
|
| 147 |
+
currentImage={null}
|
| 148 |
+
disabled={isGenerating || loading}
|
| 149 |
+
/>
|
| 150 |
+
<ExampleGallery
|
| 151 |
+
onSelect={handleExampleSelect}
|
| 152 |
+
disabled={isGenerating || loading}
|
| 153 |
+
/>
|
| 154 |
+
</div>
|
| 155 |
+
)}
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
);
|
| 160 |
+
}
|
src/components/DetectionOverlay.tsx
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from "react";
|
| 2 |
+
import type { Detection } from "../hooks/LLMContext";
|
| 3 |
+
import { toPixelCoords, DETECTION_COLORS } from "../utils/detection-parser";
|
| 4 |
+
|
| 5 |
+
interface DetectionOverlayProps {
|
| 6 |
+
imageUrl: string;
|
| 7 |
+
detections: Detection[];
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function DetectionOverlay({ imageUrl, detections }: DetectionOverlayProps) {
|
| 11 |
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 12 |
+
const imgRef = useRef<HTMLImageElement>(null);
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
const canvas = canvasRef.current;
|
| 16 |
+
const img = imgRef.current;
|
| 17 |
+
if (!canvas || !img || !img.complete) return;
|
| 18 |
+
|
| 19 |
+
const ctx = canvas.getContext("2d");
|
| 20 |
+
if (!ctx) return;
|
| 21 |
+
|
| 22 |
+
// Match canvas size to displayed image size
|
| 23 |
+
const rect = img.getBoundingClientRect();
|
| 24 |
+
canvas.width = rect.width;
|
| 25 |
+
canvas.height = rect.height;
|
| 26 |
+
|
| 27 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 28 |
+
|
| 29 |
+
// Build unique label map for consistent colors
|
| 30 |
+
const labelSet = [...new Set(detections.map((d) => d.label))];
|
| 31 |
+
|
| 32 |
+
for (const det of detections) {
|
| 33 |
+
const [x1, y1, x2, y2] = toPixelCoords(
|
| 34 |
+
det.box_2d,
|
| 35 |
+
canvas.width,
|
| 36 |
+
canvas.height,
|
| 37 |
+
);
|
| 38 |
+
const colorIdx = labelSet.indexOf(det.label);
|
| 39 |
+
const color = DETECTION_COLORS[colorIdx % DETECTION_COLORS.length];
|
| 40 |
+
|
| 41 |
+
// Draw box
|
| 42 |
+
ctx.strokeStyle = color;
|
| 43 |
+
ctx.lineWidth = 2.5;
|
| 44 |
+
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
| 45 |
+
|
| 46 |
+
// Draw label background
|
| 47 |
+
ctx.font = "bold 13px 'Google Sans', sans-serif";
|
| 48 |
+
const textWidth = ctx.measureText(det.label).width;
|
| 49 |
+
const labelHeight = 20;
|
| 50 |
+
const labelY = Math.max(0, y1 - labelHeight);
|
| 51 |
+
|
| 52 |
+
ctx.fillStyle = color;
|
| 53 |
+
ctx.beginPath();
|
| 54 |
+
ctx.roundRect(x1, labelY, textWidth + 10, labelHeight, 4);
|
| 55 |
+
ctx.fill();
|
| 56 |
+
|
| 57 |
+
// Draw label text
|
| 58 |
+
ctx.fillStyle = "#ffffff";
|
| 59 |
+
ctx.fillText(det.label, x1 + 5, labelY + 14);
|
| 60 |
+
}
|
| 61 |
+
}, [detections]);
|
| 62 |
+
|
| 63 |
+
const handleImageLoad = () => {
|
| 64 |
+
// Re-trigger detection rendering once image loads
|
| 65 |
+
if (detections.length > 0) {
|
| 66 |
+
const canvas = canvasRef.current;
|
| 67 |
+
const img = imgRef.current;
|
| 68 |
+
if (!canvas || !img) return;
|
| 69 |
+
|
| 70 |
+
const ctx = canvas.getContext("2d");
|
| 71 |
+
if (!ctx) return;
|
| 72 |
+
|
| 73 |
+
const rect = img.getBoundingClientRect();
|
| 74 |
+
canvas.width = rect.width;
|
| 75 |
+
canvas.height = rect.height;
|
| 76 |
+
|
| 77 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 78 |
+
|
| 79 |
+
const labelSet = [...new Set(detections.map((d) => d.label))];
|
| 80 |
+
|
| 81 |
+
for (const det of detections) {
|
| 82 |
+
const [x1, y1, x2, y2] = toPixelCoords(
|
| 83 |
+
det.box_2d,
|
| 84 |
+
canvas.width,
|
| 85 |
+
canvas.height,
|
| 86 |
+
);
|
| 87 |
+
const colorIdx = labelSet.indexOf(det.label);
|
| 88 |
+
const color = DETECTION_COLORS[colorIdx % DETECTION_COLORS.length];
|
| 89 |
+
|
| 90 |
+
ctx.strokeStyle = color;
|
| 91 |
+
ctx.lineWidth = 2.5;
|
| 92 |
+
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
| 93 |
+
|
| 94 |
+
ctx.font = "bold 13px 'Google Sans', sans-serif";
|
| 95 |
+
const textWidth = ctx.measureText(det.label).width;
|
| 96 |
+
const labelHeight = 20;
|
| 97 |
+
const labelY = Math.max(0, y1 - labelHeight);
|
| 98 |
+
|
| 99 |
+
ctx.fillStyle = color;
|
| 100 |
+
ctx.beginPath();
|
| 101 |
+
ctx.roundRect(x1, labelY, textWidth + 10, labelHeight, 4);
|
| 102 |
+
ctx.fill();
|
| 103 |
+
|
| 104 |
+
ctx.fillStyle = "#ffffff";
|
| 105 |
+
ctx.fillText(det.label, x1 + 5, labelY + 14);
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
return (
|
| 111 |
+
<div className="relative inline-block w-full">
|
| 112 |
+
<img
|
| 113 |
+
ref={imgRef}
|
| 114 |
+
src={imageUrl}
|
| 115 |
+
alt="Detection result"
|
| 116 |
+
className="w-full max-h-[500px] object-contain rounded-xl"
|
| 117 |
+
onLoad={handleImageLoad}
|
| 118 |
+
/>
|
| 119 |
+
<canvas
|
| 120 |
+
ref={canvasRef}
|
| 121 |
+
className="absolute top-0 left-0 w-full h-full pointer-events-none"
|
| 122 |
+
style={{ objectFit: "contain" }}
|
| 123 |
+
/>
|
| 124 |
+
</div>
|
| 125 |
+
);
|
| 126 |
+
}
|
src/components/ExampleGallery.tsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { COCO_EXAMPLES } from "../utils/coco-examples";
|
| 2 |
+
|
| 3 |
+
interface ExampleGalleryProps {
|
| 4 |
+
onSelect: (url: string, prompt: string) => void;
|
| 5 |
+
disabled?: boolean;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export function ExampleGallery({ onSelect, disabled }: ExampleGalleryProps) {
|
| 9 |
+
return (
|
| 10 |
+
<div className={disabled ? "opacity-50 pointer-events-none" : ""}>
|
| 11 |
+
<p className="text-sm font-medium text-white/60 mb-3">
|
| 12 |
+
Or try an example:
|
| 13 |
+
</p>
|
| 14 |
+
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
|
| 15 |
+
{COCO_EXAMPLES.map((example) => (
|
| 16 |
+
<button
|
| 17 |
+
key={example.label}
|
| 18 |
+
onClick={() => onSelect(example.url, example.prompt)}
|
| 19 |
+
className="group relative overflow-hidden rounded-xl border border-white/10 bg-white/5 transition-all hover:border-[#1a73e8]/60 hover:shadow-lg hover:shadow-[#1a73e8]/20 cursor-pointer"
|
| 20 |
+
>
|
| 21 |
+
<div className="aspect-square overflow-hidden">
|
| 22 |
+
<img
|
| 23 |
+
src={example.url}
|
| 24 |
+
alt={example.label}
|
| 25 |
+
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
| 26 |
+
loading="lazy"
|
| 27 |
+
/>
|
| 28 |
+
</div>
|
| 29 |
+
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end p-2">
|
| 30 |
+
<span className="text-xs font-medium text-white">{example.label}</span>
|
| 31 |
+
</div>
|
| 32 |
+
</button>
|
| 33 |
+
))}
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
);
|
| 37 |
+
}
|
src/components/GemmaIntro.tsx
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Liquid effect adapted from https://www.framer.com/@kevin-levron/
|
| 3 |
+
*/
|
| 4 |
+
import { useEffect, useRef, useCallback, useState } from "react";
|
| 5 |
+
import LiquidBackground from "../utils/liquid1.min.js";
|
| 6 |
+
|
| 7 |
+
type LiquidApp = ReturnType<typeof LiquidBackground>;
|
| 8 |
+
|
| 9 |
+
interface GemmaIntroProps {
|
| 10 |
+
onEnter: () => void;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function GemmaIntro({ onEnter }: GemmaIntroProps) {
|
| 14 |
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 15 |
+
const appRef = useRef<LiquidApp | null>(null);
|
| 16 |
+
const [fading, setFading] = useState(false);
|
| 17 |
+
const [ready, setReady] = useState(false);
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
let disposed = false;
|
| 21 |
+
|
| 22 |
+
async function init() {
|
| 23 |
+
if (disposed || !canvasRef.current) return;
|
| 24 |
+
|
| 25 |
+
const app = LiquidBackground(canvasRef.current);
|
| 26 |
+
appRef.current = app;
|
| 27 |
+
|
| 28 |
+
app.loadImage("/gemma4.png");
|
| 29 |
+
app.liquidPlane.material.metalness = 0.82;
|
| 30 |
+
app.liquidPlane.material.roughness = 0.48;
|
| 31 |
+
app.liquidPlane.uniforms.displacementScale.value = 6;
|
| 32 |
+
app.setRain(false);
|
| 33 |
+
|
| 34 |
+
setTimeout(() => {
|
| 35 |
+
if (!disposed) setReady(true);
|
| 36 |
+
}, 300);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
init();
|
| 40 |
+
|
| 41 |
+
return () => {
|
| 42 |
+
disposed = true;
|
| 43 |
+
if (appRef.current) {
|
| 44 |
+
appRef.current.dispose();
|
| 45 |
+
appRef.current = null;
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
}, []);
|
| 49 |
+
|
| 50 |
+
const handleClick = useCallback(() => {
|
| 51 |
+
if (fading) return;
|
| 52 |
+
setFading(true);
|
| 53 |
+
setTimeout(() => {
|
| 54 |
+
if (appRef.current) {
|
| 55 |
+
appRef.current.dispose();
|
| 56 |
+
appRef.current = null;
|
| 57 |
+
}
|
| 58 |
+
onEnter();
|
| 59 |
+
}, 600);
|
| 60 |
+
}, [fading, onEnter]);
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div
|
| 64 |
+
className="absolute inset-0 z-30 cursor-pointer bg-black"
|
| 65 |
+
onClick={handleClick}
|
| 66 |
+
>
|
| 67 |
+
<canvas
|
| 68 |
+
ref={canvasRef}
|
| 69 |
+
className={`absolute inset-0 w-full h-full transition-opacity duration-700 ${
|
| 70 |
+
ready ? "opacity-100" : "opacity-0"
|
| 71 |
+
}`}
|
| 72 |
+
/>
|
| 73 |
+
|
| 74 |
+
<div
|
| 75 |
+
className={`absolute inset-0 flex flex-col items-center justify-end transition-opacity duration-500 pb-10 ${
|
| 76 |
+
fading ? "opacity-0" : ready ? "opacity-100" : "opacity-0"
|
| 77 |
+
}`}
|
| 78 |
+
>
|
| 79 |
+
<h1 className="text-6xl sm:text-7xl font-bold tracking-tight select-none drop-shadow-[0_2px_12px_rgba(0,0,0,0.5)]">
|
| 80 |
+
<span className="text-white">Gemma 4</span>{" "}
|
| 81 |
+
<span className="text-white/90">WebGPU</span>
|
| 82 |
+
</h1>
|
| 83 |
+
|
| 84 |
+
<p className="mt-6 text-lg text-white/80 select-none animate-pulse-gentle drop-shadow-[0_1px_6px_rgba(0,0,0,0.5)]">
|
| 85 |
+
Click anywhere to start
|
| 86 |
+
</p>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<div
|
| 90 |
+
className={`absolute inset-0 bg-black transition-opacity duration-600 pointer-events-none ${
|
| 91 |
+
fading ? "opacity-100" : "opacity-0"
|
| 92 |
+
}`}
|
| 93 |
+
/>
|
| 94 |
+
</div>
|
| 95 |
+
);
|
| 96 |
+
}
|
src/components/ImageUpload.tsx
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useRef, useState } from "react";
|
| 2 |
+
import { Upload, ImageIcon, X } from "lucide-react";
|
| 3 |
+
import { resizeImage } from "../utils/image-utils";
|
| 4 |
+
|
| 5 |
+
interface ImageUploadProps {
|
| 6 |
+
onImageSelect: (dataUrl: string) => void;
|
| 7 |
+
currentImage: string | null;
|
| 8 |
+
disabled?: boolean;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function ImageUpload({ onImageSelect, currentImage, disabled }: ImageUploadProps) {
|
| 12 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 13 |
+
const [isDragging, setIsDragging] = useState(false);
|
| 14 |
+
|
| 15 |
+
const handleFile = useCallback(
|
| 16 |
+
async (file: File) => {
|
| 17 |
+
if (!file.type.startsWith("image/")) {
|
| 18 |
+
console.warn("[Gemma4] Ignoring non-image file:", file.type);
|
| 19 |
+
return;
|
| 20 |
+
}
|
| 21 |
+
try {
|
| 22 |
+
const dataUrl = await resizeImage(file);
|
| 23 |
+
onImageSelect(dataUrl);
|
| 24 |
+
} catch (err) {
|
| 25 |
+
console.error("[Gemma4] Failed to read image:", err);
|
| 26 |
+
alert(`Failed to read image: ${err instanceof Error ? err.message : String(err)}`);
|
| 27 |
+
}
|
| 28 |
+
},
|
| 29 |
+
[onImageSelect],
|
| 30 |
+
);
|
| 31 |
+
|
| 32 |
+
const handleDrop = useCallback(
|
| 33 |
+
(e: React.DragEvent) => {
|
| 34 |
+
e.preventDefault();
|
| 35 |
+
setIsDragging(false);
|
| 36 |
+
const file = e.dataTransfer.files[0];
|
| 37 |
+
if (file) handleFile(file);
|
| 38 |
+
},
|
| 39 |
+
[handleFile],
|
| 40 |
+
);
|
| 41 |
+
|
| 42 |
+
const handlePaste = useCallback(
|
| 43 |
+
(e: React.ClipboardEvent) => {
|
| 44 |
+
const items = e.clipboardData.items;
|
| 45 |
+
for (const item of items) {
|
| 46 |
+
if (item.type.startsWith("image/")) {
|
| 47 |
+
const file = item.getAsFile();
|
| 48 |
+
if (file) handleFile(file);
|
| 49 |
+
break;
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
},
|
| 53 |
+
[handleFile],
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
if (currentImage) {
|
| 57 |
+
return (
|
| 58 |
+
<div className="relative group">
|
| 59 |
+
<img
|
| 60 |
+
src={currentImage}
|
| 61 |
+
alt="Selected"
|
| 62 |
+
className="w-full max-h-[400px] object-contain rounded-xl border border-white/10"
|
| 63 |
+
/>
|
| 64 |
+
{!disabled && (
|
| 65 |
+
<button
|
| 66 |
+
onClick={() => onImageSelect("")}
|
| 67 |
+
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/60 text-white/70 hover:text-white hover:bg-red-500/80 transition-colors opacity-0 group-hover:opacity-100"
|
| 68 |
+
>
|
| 69 |
+
<X className="h-4 w-4" />
|
| 70 |
+
</button>
|
| 71 |
+
)}
|
| 72 |
+
</div>
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
return (
|
| 77 |
+
<div
|
| 78 |
+
onDragOver={(e) => {
|
| 79 |
+
e.preventDefault();
|
| 80 |
+
setIsDragging(true);
|
| 81 |
+
}}
|
| 82 |
+
onDragLeave={() => setIsDragging(false)}
|
| 83 |
+
onDrop={handleDrop}
|
| 84 |
+
onPaste={handlePaste}
|
| 85 |
+
tabIndex={0}
|
| 86 |
+
className={`flex flex-col items-center justify-center gap-4 rounded-2xl border-2 border-dashed p-10 transition-all cursor-pointer ${
|
| 87 |
+
isDragging
|
| 88 |
+
? "border-[#1a73e8] bg-[#1a73e8]/10"
|
| 89 |
+
: "border-white/10 hover:border-white/30 bg-white/5"
|
| 90 |
+
} ${disabled ? "opacity-50 pointer-events-none" : ""}`}
|
| 91 |
+
onClick={() => fileInputRef.current?.click()}
|
| 92 |
+
>
|
| 93 |
+
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#1a73e8]/10 border border-white/10">
|
| 94 |
+
{isDragging ? (
|
| 95 |
+
<Upload className="h-8 w-8 text-[#1a73e8]" />
|
| 96 |
+
) : (
|
| 97 |
+
<ImageIcon className="h-8 w-8 text-[#1a73e8]" />
|
| 98 |
+
)}
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div className="text-center">
|
| 102 |
+
<p className="text-base font-medium text-white">
|
| 103 |
+
{isDragging ? "Drop image here" : "Upload an image"}
|
| 104 |
+
</p>
|
| 105 |
+
<p className="mt-1 text-sm text-white/60">
|
| 106 |
+
Drag & drop, click to browse, or paste from clipboard
|
| 107 |
+
</p>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<input
|
| 111 |
+
ref={fileInputRef}
|
| 112 |
+
type="file"
|
| 113 |
+
accept="image/*"
|
| 114 |
+
className="hidden"
|
| 115 |
+
onChange={(e) => {
|
| 116 |
+
const file = e.target.files?.[0];
|
| 117 |
+
if (file) handleFile(file);
|
| 118 |
+
e.target.value = "";
|
| 119 |
+
}}
|
| 120 |
+
/>
|
| 121 |
+
</div>
|
| 122 |
+
);
|
| 123 |
+
}
|
src/components/LoadingScreen.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Loader2 } from "lucide-react";
|
| 2 |
+
import type { LoadingStatus } from "../hooks/LLMContext";
|
| 3 |
+
|
| 4 |
+
interface LoadingScreenProps {
|
| 5 |
+
status: LoadingStatus;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export function LoadingScreen({ status }: LoadingScreenProps) {
|
| 9 |
+
const progress =
|
| 10 |
+
status.state === "ready"
|
| 11 |
+
? 100
|
| 12 |
+
: status.state === "loading" && typeof status.progress === "number"
|
| 13 |
+
? status.progress
|
| 14 |
+
: 0;
|
| 15 |
+
|
| 16 |
+
const message =
|
| 17 |
+
status.state === "loading"
|
| 18 |
+
? status.message ?? "Loading model..."
|
| 19 |
+
: status.state === "error"
|
| 20 |
+
? "Failed to load model"
|
| 21 |
+
: "Initializing...";
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<div className="absolute inset-0 flex items-center justify-center bg-[#0a0a0a] text-white animate-fade-in">
|
| 25 |
+
<div className="w-full max-w-md px-6 flex flex-col items-center">
|
| 26 |
+
<div className="mb-10 flex items-center gap-3">
|
| 27 |
+
<div className="w-9 h-9 rounded-lg bg-[#1a73e8] flex items-center justify-center">
|
| 28 |
+
<svg viewBox="0 0 100 100" className="w-5 h-5">
|
| 29 |
+
<polygon
|
| 30 |
+
points="50,10 85,35 72,85 28,85 15,35"
|
| 31 |
+
fill="rgba(255,255,255,0.95)"
|
| 32 |
+
/>
|
| 33 |
+
<polygon
|
| 34 |
+
points="50,10 62,35 50,85 38,35"
|
| 35 |
+
fill="rgba(255,255,255,0.55)"
|
| 36 |
+
/>
|
| 37 |
+
</svg>
|
| 38 |
+
</div>
|
| 39 |
+
<span className="text-lg font-medium tracking-tight">Gemma 4</span>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
{status.state !== "error" && (
|
| 43 |
+
<Loader2 className="h-8 w-8 animate-spin text-[#1a73e8] mb-4" />
|
| 44 |
+
)}
|
| 45 |
+
|
| 46 |
+
<p
|
| 47 |
+
className={`text-sm tracking-wide ${
|
| 48 |
+
status.state === "error"
|
| 49 |
+
? "text-red-400 font-medium"
|
| 50 |
+
: "text-white/70"
|
| 51 |
+
}`}
|
| 52 |
+
>
|
| 53 |
+
{message}
|
| 54 |
+
</p>
|
| 55 |
+
|
| 56 |
+
<div className="mt-6 h-1 w-full rounded-full bg-white/10 overflow-hidden">
|
| 57 |
+
<div
|
| 58 |
+
className="h-full rounded-full bg-[#1a73e8] transition-[width] duration-300 ease-out"
|
| 59 |
+
style={{ width: `${progress}%` }}
|
| 60 |
+
/>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
<p className="mt-4 text-xs text-white/40">
|
| 64 |
+
Cached after first load · Runs 100% offline
|
| 65 |
+
</p>
|
| 66 |
+
|
| 67 |
+
{status.state === "error" && (
|
| 68 |
+
<p className="mt-3 text-sm text-red-400 text-center">{status.error}</p>
|
| 69 |
+
)}
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
);
|
| 73 |
+
}
|
src/components/ResultPanel.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Loader2 } from "lucide-react";
|
| 2 |
+
import type { DetectionResult } from "../hooks/LLMContext";
|
| 3 |
+
|
| 4 |
+
interface ResultPanelProps {
|
| 5 |
+
result: DetectionResult | null;
|
| 6 |
+
isGenerating: boolean;
|
| 7 |
+
tps: number;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function ResultPanel({ result, isGenerating, tps }: ResultPanelProps) {
|
| 11 |
+
if (!result && !isGenerating) return null;
|
| 12 |
+
|
| 13 |
+
return (
|
| 14 |
+
<div className="rounded-2xl border border-white/10 bg-white/5 p-4 sm:p-5">
|
| 15 |
+
<div className="flex items-center justify-between mb-3">
|
| 16 |
+
<h3 className="text-sm font-medium text-white/60">
|
| 17 |
+
{isGenerating ? (
|
| 18 |
+
<span className="detection-shimmer">Analyzing image...</span>
|
| 19 |
+
) : (
|
| 20 |
+
`${result?.detections.length ?? 0} object(s) detected`
|
| 21 |
+
)}
|
| 22 |
+
</h3>
|
| 23 |
+
{tps > 0 && (
|
| 24 |
+
<span className="text-xs text-white/50 font-mono">
|
| 25 |
+
{tps} tok/s
|
| 26 |
+
</span>
|
| 27 |
+
)}
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
{isGenerating && !result?.text && (
|
| 31 |
+
<div className="flex items-center gap-2 text-[#1a73e8]">
|
| 32 |
+
<Loader2 className="h-4 w-4 animate-spin" />
|
| 33 |
+
<span className="text-sm">Processing...</span>
|
| 34 |
+
</div>
|
| 35 |
+
)}
|
| 36 |
+
|
| 37 |
+
{result?.text && (
|
| 38 |
+
<pre className="text-sm text-white/80 whitespace-pre-wrap break-words font-mono leading-relaxed bg-black/40 rounded-xl p-3 max-h-[200px] overflow-y-auto">
|
| 39 |
+
{result.text}
|
| 40 |
+
</pre>
|
| 41 |
+
)}
|
| 42 |
+
|
| 43 |
+
{result && result.detections.length > 0 && !isGenerating && (
|
| 44 |
+
<div className="mt-3 flex flex-wrap gap-2">
|
| 45 |
+
{result.detections.map((det, i) => (
|
| 46 |
+
<span
|
| 47 |
+
key={i}
|
| 48 |
+
className="inline-flex items-center gap-1.5 rounded-lg bg-[#1a73e8]/15 border border-[#1a73e8]/40 px-2.5 py-1 text-xs font-medium text-[#8bb4f0]"
|
| 49 |
+
>
|
| 50 |
+
<span className="w-2 h-2 rounded-full bg-[#1a73e8]" />
|
| 51 |
+
{det.label}
|
| 52 |
+
</span>
|
| 53 |
+
))}
|
| 54 |
+
</div>
|
| 55 |
+
)}
|
| 56 |
+
</div>
|
| 57 |
+
);
|
| 58 |
+
}
|
src/hooks/LLMContext.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createContext } from "react";
|
| 2 |
+
|
| 3 |
+
export interface Detection {
|
| 4 |
+
box_2d: [number, number, number, number]; // [y1, x1, y2, x2] normalized 0-1000
|
| 5 |
+
label: string;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export interface DetectionResult {
|
| 9 |
+
text: string;
|
| 10 |
+
detections: Detection[];
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export type LoadingStatus =
|
| 14 |
+
| { state: "idle" }
|
| 15 |
+
| { state: "loading"; progress?: number; message?: string }
|
| 16 |
+
| { state: "ready" }
|
| 17 |
+
| { state: "error"; error: string };
|
| 18 |
+
|
| 19 |
+
export interface LLMContextValue {
|
| 20 |
+
status: LoadingStatus;
|
| 21 |
+
isGenerating: boolean;
|
| 22 |
+
tps: number;
|
| 23 |
+
dtype: "q4f16" | "q4" | null;
|
| 24 |
+
result: DetectionResult | null;
|
| 25 |
+
loadModel: () => void;
|
| 26 |
+
detect: (image: string, prompt: string) => void;
|
| 27 |
+
stop: () => void;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export const LLMContext = createContext<LLMContextValue | null>(null);
|
src/hooks/LLMProvider.tsx
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useRef, useState, useCallback, type ReactNode } from "react";
|
| 2 |
+
import {
|
| 3 |
+
AutoProcessor,
|
| 4 |
+
Gemma4ForConditionalGeneration,
|
| 5 |
+
TextStreamer,
|
| 6 |
+
load_image,
|
| 7 |
+
} from "@huggingface/transformers";
|
| 8 |
+
import { LLMContext, type LoadingStatus, type DetectionResult } from "./LLMContext";
|
| 9 |
+
import { parseJsonDetection } from "../utils/detection-parser";
|
| 10 |
+
|
| 11 |
+
const MODEL_ID = "onnx-community/gemma-4-E2B-it-ONNX";
|
| 12 |
+
|
| 13 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 14 |
+
type AnyModel = any;
|
| 15 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 16 |
+
type AnyProcessor = any;
|
| 17 |
+
|
| 18 |
+
async function pickDtype(): Promise<"q4f16" | "q4"> {
|
| 19 |
+
try {
|
| 20 |
+
const adapter = await navigator.gpu?.requestAdapter({
|
| 21 |
+
powerPreference: "high-performance",
|
| 22 |
+
});
|
| 23 |
+
if (adapter) {
|
| 24 |
+
const features = [...adapter.features];
|
| 25 |
+
console.log("[Gemma4] WebGPU adapter features:", features);
|
| 26 |
+
console.log("[Gemma4] WebGPU adapter limits:", adapter.limits);
|
| 27 |
+
if (adapter.features.has("shader-f16")) {
|
| 28 |
+
console.log("[Gemma4] shader-f16 is AVAILABLE — using q4f16 (fast)");
|
| 29 |
+
return "q4f16";
|
| 30 |
+
}
|
| 31 |
+
console.log("[Gemma4] shader-f16 NOT available — falling back to q4 (slow)");
|
| 32 |
+
}
|
| 33 |
+
} catch (err) {
|
| 34 |
+
console.warn("[Gemma4] Adapter probe failed:", err);
|
| 35 |
+
}
|
| 36 |
+
return "q4";
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export function LLMProvider({ children }: { children: ReactNode }) {
|
| 40 |
+
const modelRef = useRef<Promise<AnyModel> | null>(null);
|
| 41 |
+
const processorRef = useRef<AnyProcessor | null>(null);
|
| 42 |
+
|
| 43 |
+
const [status, setStatus] = useState<LoadingStatus>({ state: "idle" });
|
| 44 |
+
const [isGenerating, setIsGenerating] = useState(false);
|
| 45 |
+
const isGeneratingRef = useRef(false);
|
| 46 |
+
const [tps, setTps] = useState(0);
|
| 47 |
+
const [dtype, setDtype] = useState<"q4f16" | "q4" | null>(null);
|
| 48 |
+
const [result, setResult] = useState<DetectionResult | null>(null);
|
| 49 |
+
const abortRef = useRef(false);
|
| 50 |
+
|
| 51 |
+
isGeneratingRef.current = isGenerating;
|
| 52 |
+
|
| 53 |
+
const loadModel = useCallback(() => {
|
| 54 |
+
if (modelRef.current) return;
|
| 55 |
+
|
| 56 |
+
modelRef.current = (async () => {
|
| 57 |
+
setStatus({ state: "loading", message: "Loading processor..." });
|
| 58 |
+
|
| 59 |
+
try {
|
| 60 |
+
const pickedDtype = await pickDtype();
|
| 61 |
+
setDtype(pickedDtype);
|
| 62 |
+
console.log(`[Gemma4] Using dtype: ${pickedDtype}`);
|
| 63 |
+
|
| 64 |
+
const processor = await AutoProcessor.from_pretrained(MODEL_ID);
|
| 65 |
+
processorRef.current = processor;
|
| 66 |
+
|
| 67 |
+
setStatus({ state: "loading", message: "Downloading model..." });
|
| 68 |
+
|
| 69 |
+
const model = await Gemma4ForConditionalGeneration.from_pretrained(MODEL_ID, {
|
| 70 |
+
dtype: pickedDtype,
|
| 71 |
+
device: "webgpu",
|
| 72 |
+
progress_callback: (p: { status: string; progress?: number }) => {
|
| 73 |
+
// Use aggregated progress_total event for smooth, monotonic progress.
|
| 74 |
+
if (p.status === "progress_total" && typeof p.progress === "number") {
|
| 75 |
+
setStatus({
|
| 76 |
+
state: "loading",
|
| 77 |
+
progress: p.progress,
|
| 78 |
+
message: "Loading model...",
|
| 79 |
+
});
|
| 80 |
+
}
|
| 81 |
+
},
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
setStatus({ state: "ready" });
|
| 85 |
+
return model;
|
| 86 |
+
} catch (err) {
|
| 87 |
+
console.error("[Gemma4] Model loading failed:", err);
|
| 88 |
+
const msg = err instanceof Error ? err.message : String(err);
|
| 89 |
+
setStatus({ state: "error", error: msg });
|
| 90 |
+
modelRef.current = null;
|
| 91 |
+
throw err;
|
| 92 |
+
}
|
| 93 |
+
})();
|
| 94 |
+
}, []);
|
| 95 |
+
|
| 96 |
+
const detect = useCallback(async (imageUrl: string, prompt: string) => {
|
| 97 |
+
if (!modelRef.current) {
|
| 98 |
+
console.warn("[Gemma4] detect() called before model loaded");
|
| 99 |
+
return;
|
| 100 |
+
}
|
| 101 |
+
if (isGeneratingRef.current) {
|
| 102 |
+
console.warn("[Gemma4] detect() called while already generating");
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
console.log("[Gemma4] Starting detection...");
|
| 107 |
+
setIsGenerating(true);
|
| 108 |
+
setTps(0);
|
| 109 |
+
setResult(null);
|
| 110 |
+
abortRef.current = false;
|
| 111 |
+
|
| 112 |
+
try {
|
| 113 |
+
const model = await modelRef.current;
|
| 114 |
+
const processor = processorRef.current!;
|
| 115 |
+
|
| 116 |
+
// Build multimodal message
|
| 117 |
+
const messages = [
|
| 118 |
+
{
|
| 119 |
+
role: "user",
|
| 120 |
+
content: [
|
| 121 |
+
{ type: "image" as const },
|
| 122 |
+
{ type: "text" as const, text: prompt },
|
| 123 |
+
],
|
| 124 |
+
},
|
| 125 |
+
];
|
| 126 |
+
|
| 127 |
+
// Apply chat template
|
| 128 |
+
const text = processor.apply_chat_template(messages, {
|
| 129 |
+
enable_thinking: false,
|
| 130 |
+
add_generation_prompt: true,
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
// Load and process image
|
| 134 |
+
const image = await load_image(imageUrl);
|
| 135 |
+
const inputs = await processor(text, image, null, {
|
| 136 |
+
add_special_tokens: false,
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
// Stream generation
|
| 140 |
+
let outputText = "";
|
| 141 |
+
let tokenCount = 0;
|
| 142 |
+
let firstTokenTime = 0;
|
| 143 |
+
|
| 144 |
+
const streamer = new TextStreamer(processor.tokenizer, {
|
| 145 |
+
skip_prompt: true,
|
| 146 |
+
skip_special_tokens: true,
|
| 147 |
+
callback_function: (token: string) => {
|
| 148 |
+
if (abortRef.current) return;
|
| 149 |
+
outputText += token;
|
| 150 |
+
const detections = parseJsonDetection(outputText);
|
| 151 |
+
setResult({ text: outputText, detections });
|
| 152 |
+
},
|
| 153 |
+
token_callback_function: () => {
|
| 154 |
+
tokenCount++;
|
| 155 |
+
if (tokenCount === 1) {
|
| 156 |
+
firstTokenTime = performance.now();
|
| 157 |
+
} else {
|
| 158 |
+
const elapsed = (performance.now() - firstTokenTime) / 1000;
|
| 159 |
+
if (elapsed > 0) {
|
| 160 |
+
setTps(Math.round(((tokenCount - 1) / elapsed) * 10) / 10);
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
},
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
await model.generate({
|
| 167 |
+
...inputs,
|
| 168 |
+
max_new_tokens: 128,
|
| 169 |
+
do_sample: true,
|
| 170 |
+
temperature: 1.0,
|
| 171 |
+
top_p: 0.95,
|
| 172 |
+
top_k: 64,
|
| 173 |
+
streamer,
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
// Final parse
|
| 177 |
+
const detections = parseJsonDetection(outputText);
|
| 178 |
+
setResult({ text: outputText, detections });
|
| 179 |
+
console.log(`[Gemma4] Detection done. ${detections.length} objects found.`);
|
| 180 |
+
} catch (err) {
|
| 181 |
+
console.error("[Gemma4] Detection error:", err);
|
| 182 |
+
const rawMsg = err instanceof Error ? err.message : String(err);
|
| 183 |
+
const isOOM =
|
| 184 |
+
rawMsg.includes("OUT_OF_DEVICE_MEMORY") ||
|
| 185 |
+
rawMsg.includes("Invalid Buffer") ||
|
| 186 |
+
rawMsg.includes("Softmax");
|
| 187 |
+
const friendly = isOOM
|
| 188 |
+
? "GPU ran out of memory. Enable chrome://flags/#enable-unsafe-webgpu and chrome://flags/#enable-vulkan, restart Chrome, and close other GPU-heavy tabs."
|
| 189 |
+
: rawMsg;
|
| 190 |
+
setResult({ text: `Error: ${friendly}`, detections: [] });
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
setIsGenerating(false);
|
| 194 |
+
}, []);
|
| 195 |
+
|
| 196 |
+
const stop = useCallback(() => {
|
| 197 |
+
abortRef.current = true;
|
| 198 |
+
}, []);
|
| 199 |
+
|
| 200 |
+
return (
|
| 201 |
+
<LLMContext.Provider
|
| 202 |
+
value={{ status, isGenerating, tps, dtype, result, loadModel, detect, stop }}
|
| 203 |
+
>
|
| 204 |
+
{children}
|
| 205 |
+
</LLMContext.Provider>
|
| 206 |
+
);
|
| 207 |
+
}
|
src/hooks/useLLM.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useContext } from "react";
|
| 2 |
+
import { LLMContext } from "./LLMContext";
|
| 3 |
+
|
| 4 |
+
export function useLLM() {
|
| 5 |
+
const ctx = useContext(LLMContext);
|
| 6 |
+
if (!ctx) throw new Error("useLLM must be used inside <LLMProvider>");
|
| 7 |
+
return ctx;
|
| 8 |
+
}
|
src/index.css
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
@layer base {
|
| 4 |
+
html,
|
| 5 |
+
body {
|
| 6 |
+
font-family:
|
| 7 |
+
"Google Sans",
|
| 8 |
+
Inter,
|
| 9 |
+
-apple-system,
|
| 10 |
+
BlinkMacSystemFont,
|
| 11 |
+
"Segoe UI",
|
| 12 |
+
sans-serif;
|
| 13 |
+
font-size: 16px;
|
| 14 |
+
height: 100%;
|
| 15 |
+
overflow: hidden;
|
| 16 |
+
background: #0a0a0a;
|
| 17 |
+
color: #ffffff;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
#root {
|
| 21 |
+
height: 100%;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
* {
|
| 25 |
+
scrollbar-width: thin;
|
| 26 |
+
scrollbar-color: transparent transparent;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
*:hover {
|
| 30 |
+
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
::-webkit-scrollbar {
|
| 34 |
+
width: 6px;
|
| 35 |
+
height: 6px;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
::-webkit-scrollbar-track {
|
| 39 |
+
background: transparent;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
::-webkit-scrollbar-thumb {
|
| 43 |
+
background: transparent;
|
| 44 |
+
border-radius: 3px;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
*:hover::-webkit-scrollbar-thumb {
|
| 48 |
+
background: rgba(255, 255, 255, 0.2);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
*:hover::-webkit-scrollbar-thumb:hover {
|
| 52 |
+
background: rgba(255, 255, 255, 0.35);
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
@keyframes pulse-gentle {
|
| 57 |
+
0%, 100% { opacity: 0.6; }
|
| 58 |
+
50% { opacity: 1; }
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.animate-pulse-gentle {
|
| 62 |
+
animation: pulse-gentle 2.5s ease-in-out infinite;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
@keyframes fade-in {
|
| 66 |
+
from { opacity: 0; }
|
| 67 |
+
to { opacity: 1; }
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.animate-fade-in {
|
| 71 |
+
animation: fade-in 0.5s ease-out both;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
@keyframes shimmer-detection {
|
| 75 |
+
0% { background-position: 200% 0; }
|
| 76 |
+
100% { background-position: -200% 0; }
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.detection-shimmer {
|
| 80 |
+
background: linear-gradient(
|
| 81 |
+
105deg,
|
| 82 |
+
#8c8c94 0%,
|
| 83 |
+
#8c8c94 40%,
|
| 84 |
+
#1a73e8 50%,
|
| 85 |
+
#8c8c94 60%,
|
| 86 |
+
#8c8c94 100%
|
| 87 |
+
);
|
| 88 |
+
background-size: 200% 100%;
|
| 89 |
+
background-clip: text;
|
| 90 |
+
-webkit-background-clip: text;
|
| 91 |
+
-webkit-text-fill-color: transparent;
|
| 92 |
+
animation: shimmer-detection 2s ease-in-out infinite;
|
| 93 |
+
}
|
src/main.tsx
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from "react";
|
| 2 |
+
import { createRoot } from "react-dom/client";
|
| 3 |
+
import { LLMProvider } from "./hooks/LLMProvider";
|
| 4 |
+
import App from "./App";
|
| 5 |
+
import "./index.css";
|
| 6 |
+
|
| 7 |
+
createRoot(document.getElementById("root")!).render(
|
| 8 |
+
<StrictMode>
|
| 9 |
+
<LLMProvider>
|
| 10 |
+
<App />
|
| 11 |
+
</LLMProvider>
|
| 12 |
+
</StrictMode>,
|
| 13 |
+
);
|
src/utils/coco-examples.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface CocoExample {
|
| 2 |
+
url: string;
|
| 3 |
+
prompt: string;
|
| 4 |
+
label: string;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export const DEFAULT_DETECTION_PROMPT =
|
| 8 |
+
'Detect all objects in this image. Return each detection as JSON: [{"box_2d": [y1, x1, y2, x2], "label": "object_name"}]';
|
| 9 |
+
|
| 10 |
+
const DETECT_PROMPT = DEFAULT_DETECTION_PROMPT;
|
| 11 |
+
const DESCRIBE_PROMPT =
|
| 12 |
+
"Describe this image in detail. What objects, people, and activities do you see?";
|
| 13 |
+
|
| 14 |
+
// Verified existing images in the Xenova/transformers.js-docs dataset (CORS enabled, stable)
|
| 15 |
+
const BASE = "https://huggingface.co/datasets/Xenova/transformers.js-docs/resolve/main";
|
| 16 |
+
|
| 17 |
+
export const COCO_EXAMPLES: CocoExample[] = [
|
| 18 |
+
{
|
| 19 |
+
url: `${BASE}/cats.jpg`,
|
| 20 |
+
prompt: DETECT_PROMPT,
|
| 21 |
+
label: "Cats",
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
url: `${BASE}/city-streets.jpg`,
|
| 25 |
+
prompt: DETECT_PROMPT,
|
| 26 |
+
label: "Streets",
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
url: `${BASE}/corgi.jpg`,
|
| 30 |
+
prompt: DETECT_PROMPT,
|
| 31 |
+
label: "Corgi",
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
url: `${BASE}/football-match.jpg`,
|
| 35 |
+
prompt: DETECT_PROMPT,
|
| 36 |
+
label: "Football",
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
url: `${BASE}/airport.jpg`,
|
| 40 |
+
prompt: DETECT_PROMPT,
|
| 41 |
+
label: "Airport",
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
url: `${BASE}/butterfly.jpg`,
|
| 45 |
+
prompt: DESCRIBE_PROMPT,
|
| 46 |
+
label: "Butterfly",
|
| 47 |
+
},
|
| 48 |
+
];
|
src/utils/detection-parser.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Detection } from "../hooks/LLMContext";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Parse JSON detection output from Gemma 4.
|
| 5 |
+
* Handles markdown code blocks, extra text, and partial output.
|
| 6 |
+
* Returns array of {box_2d: [y1,x1,y2,x2], label: string}.
|
| 7 |
+
*/
|
| 8 |
+
export function parseJsonDetection(text: string): Detection[] {
|
| 9 |
+
// Strip markdown code blocks
|
| 10 |
+
let cleaned = text.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim();
|
| 11 |
+
|
| 12 |
+
// Try direct parse
|
| 13 |
+
try {
|
| 14 |
+
const result = JSON.parse(cleaned);
|
| 15 |
+
if (Array.isArray(result)) return validateDetections(result);
|
| 16 |
+
} catch {
|
| 17 |
+
// fall through
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
// Try to find a JSON array in the text
|
| 21 |
+
const match = cleaned.match(/\[[\s\S]*\]/);
|
| 22 |
+
if (match) {
|
| 23 |
+
try {
|
| 24 |
+
const result = JSON.parse(match[0]);
|
| 25 |
+
if (Array.isArray(result)) return validateDetections(result);
|
| 26 |
+
} catch {
|
| 27 |
+
// fall through
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
return [];
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function validateDetections(arr: unknown[]): Detection[] {
|
| 35 |
+
return arr.filter(
|
| 36 |
+
(d): d is Detection =>
|
| 37 |
+
typeof d === "object" &&
|
| 38 |
+
d !== null &&
|
| 39 |
+
"box_2d" in d &&
|
| 40 |
+
Array.isArray((d as Detection).box_2d) &&
|
| 41 |
+
(d as Detection).box_2d.length === 4,
|
| 42 |
+
);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* Convert Gemma 4 normalized coords (0-1000) to pixel coordinates.
|
| 47 |
+
* Input: [y1, x1, y2, x2] (Gemma format, Y-first)
|
| 48 |
+
* Output: [x1, y1, x2, y2] (canvas format, X-first)
|
| 49 |
+
*/
|
| 50 |
+
export function toPixelCoords(
|
| 51 |
+
box_2d: [number, number, number, number],
|
| 52 |
+
width: number,
|
| 53 |
+
height: number,
|
| 54 |
+
): [number, number, number, number] {
|
| 55 |
+
const [y1Norm, x1Norm, y2Norm, x2Norm] = box_2d;
|
| 56 |
+
return [
|
| 57 |
+
(x1Norm / 1000) * width,
|
| 58 |
+
(y1Norm / 1000) * height,
|
| 59 |
+
(x2Norm / 1000) * width,
|
| 60 |
+
(y2Norm / 1000) * height,
|
| 61 |
+
];
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
export const DETECTION_COLORS = [
|
| 65 |
+
"#ff6b6b", "#4ecdc4", "#45b7d1", "#f9ca24",
|
| 66 |
+
"#6c5ce7", "#a8e6cf", "#fd79a8", "#fdcb6e",
|
| 67 |
+
"#0984e3", "#00b894", "#e17055", "#74b9ff",
|
| 68 |
+
];
|
src/utils/gemma-shader.ts
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Custom WebGPU crystal/gem particle animation for Gemma 4 intro.
|
| 3 |
+
* Uses compute shader for particle simulation + render pipeline for visualization.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const PARTICLE_COUNT = 2000;
|
| 7 |
+
|
| 8 |
+
const COMPUTE_SHADER = /* wgsl */ `
|
| 9 |
+
struct Particle {
|
| 10 |
+
pos: vec2f,
|
| 11 |
+
vel: vec2f,
|
| 12 |
+
life: f32,
|
| 13 |
+
size: f32,
|
| 14 |
+
hue: f32,
|
| 15 |
+
_pad: f32,
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
struct Uniforms {
|
| 19 |
+
time: f32,
|
| 20 |
+
delta: f32,
|
| 21 |
+
width: f32,
|
| 22 |
+
height: f32,
|
| 23 |
+
mouseX: f32,
|
| 24 |
+
mouseY: f32,
|
| 25 |
+
_pad0: f32,
|
| 26 |
+
_pad1: f32,
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
|
| 30 |
+
@group(0) @binding(1) var<uniform> uniforms: Uniforms;
|
| 31 |
+
|
| 32 |
+
fn hash(p: f32) -> f32 {
|
| 33 |
+
var s = fract(p * 0.1031);
|
| 34 |
+
s = s * (s + 33.33);
|
| 35 |
+
s = s * (s + s);
|
| 36 |
+
return fract(s);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
@compute @workgroup_size(64)
|
| 40 |
+
fn main(@builtin(global_invocation_id) id: vec3u) {
|
| 41 |
+
let i = id.x;
|
| 42 |
+
if (i >= ${PARTICLE_COUNT}u) { return; }
|
| 43 |
+
|
| 44 |
+
var p = particles[i];
|
| 45 |
+
let t = uniforms.time;
|
| 46 |
+
let dt = uniforms.delta;
|
| 47 |
+
|
| 48 |
+
// Update life
|
| 49 |
+
p.life -= dt * 0.15;
|
| 50 |
+
|
| 51 |
+
if (p.life <= 0.0) {
|
| 52 |
+
// Respawn from center with random direction
|
| 53 |
+
let seed = f32(i) + t * 7.31;
|
| 54 |
+
let angle = hash(seed) * 6.283185;
|
| 55 |
+
let speed = hash(seed + 1.0) * 0.3 + 0.1;
|
| 56 |
+
p.pos = vec2f(0.5, 0.5);
|
| 57 |
+
p.vel = vec2f(cos(angle) * speed, sin(angle) * speed);
|
| 58 |
+
p.life = hash(seed + 2.0) * 0.8 + 0.4;
|
| 59 |
+
p.size = hash(seed + 3.0) * 3.0 + 1.0;
|
| 60 |
+
p.hue = hash(seed + 4.0);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// Gentle attraction to mouse
|
| 64 |
+
let mousePos = vec2f(uniforms.mouseX / uniforms.width, uniforms.mouseY / uniforms.height);
|
| 65 |
+
let toMouse = mousePos - p.pos;
|
| 66 |
+
let dist = length(toMouse);
|
| 67 |
+
if (dist > 0.01) {
|
| 68 |
+
p.vel += normalize(toMouse) * 0.02 * dt / (dist + 0.2);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// Swirl effect
|
| 72 |
+
let center = vec2f(0.5, 0.5);
|
| 73 |
+
let toCenter = center - p.pos;
|
| 74 |
+
let perpendicular = vec2f(-toCenter.y, toCenter.x);
|
| 75 |
+
p.vel += perpendicular * 0.05 * dt;
|
| 76 |
+
|
| 77 |
+
// Damping
|
| 78 |
+
p.vel *= 0.995;
|
| 79 |
+
|
| 80 |
+
// Update position
|
| 81 |
+
p.pos += p.vel * dt;
|
| 82 |
+
|
| 83 |
+
// Soft boundary bounce
|
| 84 |
+
if (p.pos.x < 0.0 || p.pos.x > 1.0) { p.vel.x *= -0.5; p.pos.x = clamp(p.pos.x, 0.0, 1.0); }
|
| 85 |
+
if (p.pos.y < 0.0 || p.pos.y > 1.0) { p.vel.y *= -0.5; p.pos.y = clamp(p.pos.y, 0.0, 1.0); }
|
| 86 |
+
|
| 87 |
+
particles[i] = p;
|
| 88 |
+
}
|
| 89 |
+
`;
|
| 90 |
+
|
| 91 |
+
const VERTEX_SHADER = /* wgsl */ `
|
| 92 |
+
struct Particle {
|
| 93 |
+
pos: vec2f,
|
| 94 |
+
vel: vec2f,
|
| 95 |
+
life: f32,
|
| 96 |
+
size: f32,
|
| 97 |
+
hue: f32,
|
| 98 |
+
_pad: f32,
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
struct Uniforms {
|
| 102 |
+
time: f32,
|
| 103 |
+
delta: f32,
|
| 104 |
+
width: f32,
|
| 105 |
+
height: f32,
|
| 106 |
+
mouseX: f32,
|
| 107 |
+
mouseY: f32,
|
| 108 |
+
_pad0: f32,
|
| 109 |
+
_pad1: f32,
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
struct VSOut {
|
| 113 |
+
@builtin(position) pos: vec4f,
|
| 114 |
+
@location(0) alpha: f32,
|
| 115 |
+
@location(1) hue: f32,
|
| 116 |
+
@location(2) uv: vec2f,
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
@group(0) @binding(0) var<storage, read> particles: array<Particle>;
|
| 120 |
+
@group(0) @binding(1) var<uniform> uniforms: Uniforms;
|
| 121 |
+
|
| 122 |
+
// Quad vertices for instancing
|
| 123 |
+
const quadVerts = array<vec2f, 6>(
|
| 124 |
+
vec2f(-1, -1), vec2f(1, -1), vec2f(-1, 1),
|
| 125 |
+
vec2f(-1, 1), vec2f(1, -1), vec2f(1, 1),
|
| 126 |
+
);
|
| 127 |
+
|
| 128 |
+
@vertex
|
| 129 |
+
fn main(
|
| 130 |
+
@builtin(vertex_index) vi: u32,
|
| 131 |
+
@builtin(instance_index) ii: u32,
|
| 132 |
+
) -> VSOut {
|
| 133 |
+
let p = particles[ii];
|
| 134 |
+
let qv = quadVerts[vi];
|
| 135 |
+
|
| 136 |
+
let aspect = uniforms.width / uniforms.height;
|
| 137 |
+
let size = p.size * 0.004 * (0.3 + p.life * 0.7);
|
| 138 |
+
|
| 139 |
+
var clipPos = vec2f(
|
| 140 |
+
(p.pos.x * 2.0 - 1.0) + qv.x * size / aspect,
|
| 141 |
+
(1.0 - p.pos.y * 2.0) + qv.y * size,
|
| 142 |
+
);
|
| 143 |
+
|
| 144 |
+
var out: VSOut;
|
| 145 |
+
out.pos = vec4f(clipPos, 0.0, 1.0);
|
| 146 |
+
out.alpha = p.life;
|
| 147 |
+
out.hue = p.hue;
|
| 148 |
+
out.uv = qv * 0.5 + 0.5;
|
| 149 |
+
return out;
|
| 150 |
+
}
|
| 151 |
+
`;
|
| 152 |
+
|
| 153 |
+
const FRAGMENT_SHADER = /* wgsl */ `
|
| 154 |
+
struct FSIn {
|
| 155 |
+
@location(0) alpha: f32,
|
| 156 |
+
@location(1) hue: f32,
|
| 157 |
+
@location(2) uv: vec2f,
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
fn hsl2rgb(h: f32, s: f32, l: f32) -> vec3f {
|
| 161 |
+
let c = (1.0 - abs(2.0 * l - 1.0)) * s;
|
| 162 |
+
let x = c * (1.0 - abs(((h * 6.0) % 2.0) - 1.0));
|
| 163 |
+
let m = l - c * 0.5;
|
| 164 |
+
var rgb: vec3f;
|
| 165 |
+
let h6 = h * 6.0;
|
| 166 |
+
if (h6 < 1.0) { rgb = vec3f(c, x, 0.0); }
|
| 167 |
+
else if (h6 < 2.0) { rgb = vec3f(x, c, 0.0); }
|
| 168 |
+
else if (h6 < 3.0) { rgb = vec3f(0.0, c, x); }
|
| 169 |
+
else if (h6 < 4.0) { rgb = vec3f(0.0, x, c); }
|
| 170 |
+
else if (h6 < 5.0) { rgb = vec3f(x, 0.0, c); }
|
| 171 |
+
else { rgb = vec3f(c, 0.0, x); }
|
| 172 |
+
return rgb + m;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
@fragment
|
| 176 |
+
fn main(in: FSIn) -> @location(0) vec4f {
|
| 177 |
+
// Soft circle
|
| 178 |
+
let d = length(in.uv - 0.5) * 2.0;
|
| 179 |
+
if (d > 1.0) { discard; }
|
| 180 |
+
let softEdge = 1.0 - smoothstep(0.4, 1.0, d);
|
| 181 |
+
|
| 182 |
+
// Gemma brand hue range: blue (0.6) → purple (0.75) → teal (0.47)
|
| 183 |
+
let h = mix(0.55, 0.78, in.hue);
|
| 184 |
+
let color = hsl2rgb(h, 0.85, 0.65);
|
| 185 |
+
|
| 186 |
+
let alpha = softEdge * in.alpha * 0.7;
|
| 187 |
+
return vec4f(color * alpha, alpha);
|
| 188 |
+
}
|
| 189 |
+
`;
|
| 190 |
+
|
| 191 |
+
export interface GemmaParticleSystem {
|
| 192 |
+
render: () => void;
|
| 193 |
+
updateMouse: (x: number, y: number) => void;
|
| 194 |
+
resize: (w: number, h: number) => void;
|
| 195 |
+
destroy: () => void;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
export async function createGemmaParticles(
|
| 199 |
+
canvas: HTMLCanvasElement,
|
| 200 |
+
): Promise<GemmaParticleSystem | null> {
|
| 201 |
+
if (!navigator.gpu) return null;
|
| 202 |
+
|
| 203 |
+
const adapter = await navigator.gpu.requestAdapter();
|
| 204 |
+
if (!adapter) return null;
|
| 205 |
+
|
| 206 |
+
const device = await adapter.requestDevice();
|
| 207 |
+
const ctx = canvas.getContext("webgpu") as GPUCanvasContext | null;
|
| 208 |
+
if (!ctx) return null;
|
| 209 |
+
|
| 210 |
+
const format = navigator.gpu.getPreferredCanvasFormat();
|
| 211 |
+
ctx.configure({ device, format, alphaMode: "premultiplied" });
|
| 212 |
+
|
| 213 |
+
// Particle buffer
|
| 214 |
+
const particleData = new Float32Array(PARTICLE_COUNT * 8); // 8 floats per particle
|
| 215 |
+
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
| 216 |
+
const off = i * 8;
|
| 217 |
+
particleData[off + 0] = Math.random(); // pos.x
|
| 218 |
+
particleData[off + 1] = Math.random(); // pos.y
|
| 219 |
+
const angle = Math.random() * Math.PI * 2;
|
| 220 |
+
const speed = Math.random() * 0.2 + 0.05;
|
| 221 |
+
particleData[off + 2] = Math.cos(angle) * speed; // vel.x
|
| 222 |
+
particleData[off + 3] = Math.sin(angle) * speed; // vel.y
|
| 223 |
+
particleData[off + 4] = Math.random() * 0.8 + 0.2; // life
|
| 224 |
+
particleData[off + 5] = Math.random() * 3 + 1; // size
|
| 225 |
+
particleData[off + 6] = Math.random(); // hue
|
| 226 |
+
particleData[off + 7] = 0; // pad
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
const particleBuffer = device.createBuffer({
|
| 230 |
+
size: particleData.byteLength,
|
| 231 |
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
| 232 |
+
});
|
| 233 |
+
device.queue.writeBuffer(particleBuffer, 0, particleData);
|
| 234 |
+
|
| 235 |
+
// Uniforms buffer: time, delta, width, height, mouseX, mouseY, pad, pad
|
| 236 |
+
const uniformData = new Float32Array([0, 0.016, canvas.width, canvas.height, canvas.width / 2, canvas.height / 2, 0, 0]);
|
| 237 |
+
const uniformBuffer = device.createBuffer({
|
| 238 |
+
size: uniformData.byteLength,
|
| 239 |
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
| 240 |
+
});
|
| 241 |
+
device.queue.writeBuffer(uniformBuffer, 0, uniformData);
|
| 242 |
+
|
| 243 |
+
// Compute pipeline
|
| 244 |
+
const computePipeline = device.createComputePipeline({
|
| 245 |
+
layout: "auto",
|
| 246 |
+
compute: {
|
| 247 |
+
module: device.createShaderModule({ code: COMPUTE_SHADER }),
|
| 248 |
+
entryPoint: "main",
|
| 249 |
+
},
|
| 250 |
+
});
|
| 251 |
+
|
| 252 |
+
const computeBindGroup = device.createBindGroup({
|
| 253 |
+
layout: computePipeline.getBindGroupLayout(0),
|
| 254 |
+
entries: [
|
| 255 |
+
{ binding: 0, resource: { buffer: particleBuffer } },
|
| 256 |
+
{ binding: 1, resource: { buffer: uniformBuffer } },
|
| 257 |
+
],
|
| 258 |
+
});
|
| 259 |
+
|
| 260 |
+
// Render pipeline
|
| 261 |
+
const renderPipeline = device.createRenderPipeline({
|
| 262 |
+
layout: "auto",
|
| 263 |
+
vertex: {
|
| 264 |
+
module: device.createShaderModule({ code: VERTEX_SHADER }),
|
| 265 |
+
entryPoint: "main",
|
| 266 |
+
},
|
| 267 |
+
fragment: {
|
| 268 |
+
module: device.createShaderModule({ code: FRAGMENT_SHADER }),
|
| 269 |
+
entryPoint: "main",
|
| 270 |
+
targets: [
|
| 271 |
+
{
|
| 272 |
+
format,
|
| 273 |
+
blend: {
|
| 274 |
+
color: { srcFactor: "one", dstFactor: "one-minus-src-alpha" },
|
| 275 |
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha" },
|
| 276 |
+
},
|
| 277 |
+
},
|
| 278 |
+
],
|
| 279 |
+
},
|
| 280 |
+
primitive: { topology: "triangle-list" },
|
| 281 |
+
});
|
| 282 |
+
|
| 283 |
+
const renderBindGroup = device.createBindGroup({
|
| 284 |
+
layout: renderPipeline.getBindGroupLayout(0),
|
| 285 |
+
entries: [
|
| 286 |
+
{ binding: 0, resource: { buffer: particleBuffer } },
|
| 287 |
+
{ binding: 1, resource: { buffer: uniformBuffer } },
|
| 288 |
+
],
|
| 289 |
+
});
|
| 290 |
+
|
| 291 |
+
let mouseX = canvas.width / 2;
|
| 292 |
+
let mouseY = canvas.height / 2;
|
| 293 |
+
let time = 0;
|
| 294 |
+
let lastTime = performance.now();
|
| 295 |
+
let destroyed = false;
|
| 296 |
+
|
| 297 |
+
function render() {
|
| 298 |
+
if (destroyed) return;
|
| 299 |
+
|
| 300 |
+
const now = performance.now();
|
| 301 |
+
const dt = Math.min((now - lastTime) / 1000, 0.05);
|
| 302 |
+
lastTime = now;
|
| 303 |
+
time += dt;
|
| 304 |
+
|
| 305 |
+
uniformData[0] = time;
|
| 306 |
+
uniformData[1] = dt;
|
| 307 |
+
uniformData[2] = canvas.width;
|
| 308 |
+
uniformData[3] = canvas.height;
|
| 309 |
+
uniformData[4] = mouseX;
|
| 310 |
+
uniformData[5] = mouseY;
|
| 311 |
+
device.queue.writeBuffer(uniformBuffer, 0, uniformData);
|
| 312 |
+
|
| 313 |
+
const encoder = device.createCommandEncoder();
|
| 314 |
+
|
| 315 |
+
// Compute pass
|
| 316 |
+
const computePass = encoder.beginComputePass();
|
| 317 |
+
computePass.setPipeline(computePipeline);
|
| 318 |
+
computePass.setBindGroup(0, computeBindGroup);
|
| 319 |
+
computePass.dispatchWorkgroups(Math.ceil(PARTICLE_COUNT / 64));
|
| 320 |
+
computePass.end();
|
| 321 |
+
|
| 322 |
+
// Render pass
|
| 323 |
+
const textureView = ctx!.getCurrentTexture().createView();
|
| 324 |
+
const renderPass = encoder.beginRenderPass({
|
| 325 |
+
colorAttachments: [
|
| 326 |
+
{
|
| 327 |
+
view: textureView,
|
| 328 |
+
clearValue: { r: 0.039, g: 0.039, b: 0.102, a: 1 },
|
| 329 |
+
loadOp: "clear",
|
| 330 |
+
storeOp: "store",
|
| 331 |
+
},
|
| 332 |
+
],
|
| 333 |
+
});
|
| 334 |
+
renderPass.setPipeline(renderPipeline);
|
| 335 |
+
renderPass.setBindGroup(0, renderBindGroup);
|
| 336 |
+
renderPass.draw(6, PARTICLE_COUNT);
|
| 337 |
+
renderPass.end();
|
| 338 |
+
|
| 339 |
+
device.queue.submit([encoder.finish()]);
|
| 340 |
+
requestAnimationFrame(render);
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
return {
|
| 344 |
+
render() {
|
| 345 |
+
requestAnimationFrame(render);
|
| 346 |
+
},
|
| 347 |
+
updateMouse(x: number, y: number) {
|
| 348 |
+
mouseX = x;
|
| 349 |
+
mouseY = y;
|
| 350 |
+
},
|
| 351 |
+
resize(w: number, h: number) {
|
| 352 |
+
canvas.width = w;
|
| 353 |
+
canvas.height = h;
|
| 354 |
+
ctx!.configure({ device, format, alphaMode: "premultiplied" });
|
| 355 |
+
},
|
| 356 |
+
destroy() {
|
| 357 |
+
destroyed = true;
|
| 358 |
+
device.destroy();
|
| 359 |
+
},
|
| 360 |
+
};
|
| 361 |
+
}
|
src/utils/image-utils.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const MAX_DIMENSION = 384;
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Resize an image to fit within MAX_DIMENSION while preserving aspect ratio.
|
| 5 |
+
* Returns a base64 data URL.
|
| 6 |
+
*/
|
| 7 |
+
export function resizeImage(file: File): Promise<string> {
|
| 8 |
+
return new Promise((resolve, reject) => {
|
| 9 |
+
const reader = new FileReader();
|
| 10 |
+
reader.onload = () => {
|
| 11 |
+
const img = new Image();
|
| 12 |
+
img.onload = () => {
|
| 13 |
+
let { width, height } = img;
|
| 14 |
+
|
| 15 |
+
if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
|
| 16 |
+
const scale = MAX_DIMENSION / Math.max(width, height);
|
| 17 |
+
width = Math.round(width * scale);
|
| 18 |
+
height = Math.round(height * scale);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const canvas = document.createElement("canvas");
|
| 22 |
+
canvas.width = width;
|
| 23 |
+
canvas.height = height;
|
| 24 |
+
const ctx = canvas.getContext("2d")!;
|
| 25 |
+
ctx.drawImage(img, 0, 0, width, height);
|
| 26 |
+
resolve(canvas.toDataURL("image/jpeg", 0.9));
|
| 27 |
+
};
|
| 28 |
+
img.onerror = reject;
|
| 29 |
+
img.src = reader.result as string;
|
| 30 |
+
};
|
| 31 |
+
reader.onerror = reject;
|
| 32 |
+
reader.readAsDataURL(file);
|
| 33 |
+
});
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* Load an image URL, resize to MAX_DIMENSION, return as base64 data URL.
|
| 38 |
+
*/
|
| 39 |
+
export async function urlToDataUrl(url: string): Promise<string> {
|
| 40 |
+
const response = await fetch(url);
|
| 41 |
+
if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
|
| 42 |
+
const blob = await response.blob();
|
| 43 |
+
const file = new File([blob], "image.jpg", { type: blob.type });
|
| 44 |
+
return resizeImage(file);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* Get image dimensions from a data URL.
|
| 49 |
+
*/
|
| 50 |
+
export function getImageDimensions(
|
| 51 |
+
dataUrl: string,
|
| 52 |
+
): Promise<{ width: number; height: number }> {
|
| 53 |
+
return new Promise((resolve, reject) => {
|
| 54 |
+
const img = new Image();
|
| 55 |
+
img.onload = () => resolve({ width: img.width, height: img.height });
|
| 56 |
+
img.onerror = reject;
|
| 57 |
+
img.src = dataUrl;
|
| 58 |
+
});
|
| 59 |
+
}
|
src/utils/liquid1.min.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface LiquidApp {
|
| 2 |
+
liquidPlane: {
|
| 3 |
+
material: {
|
| 4 |
+
metalness: number;
|
| 5 |
+
roughness: number;
|
| 6 |
+
};
|
| 7 |
+
uniforms: {
|
| 8 |
+
displacementScale: { value: number };
|
| 9 |
+
};
|
| 10 |
+
};
|
| 11 |
+
loadImage(url: string): void;
|
| 12 |
+
setRain(enabled: boolean): void;
|
| 13 |
+
dispose(): void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
declare function LiquidBackground(canvas: HTMLCanvasElement): LiquidApp;
|
| 17 |
+
export default LiquidBackground;
|
src/utils/liquid1.min.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/vite-env.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="vite/client" />
|
| 2 |
+
/// <reference types="@webgpu/types" />
|
tsconfig.app.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2020",
|
| 4 |
+
"useDefineForClassFields": true,
|
| 5 |
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"skipLibCheck": true,
|
| 8 |
+
"moduleResolution": "bundler",
|
| 9 |
+
"allowImportingTsExtensions": true,
|
| 10 |
+
"isolatedModules": true,
|
| 11 |
+
"moduleDetection": "force",
|
| 12 |
+
"noEmit": true,
|
| 13 |
+
"jsx": "react-jsx",
|
| 14 |
+
"strict": true,
|
| 15 |
+
"noUnusedLocals": true,
|
| 16 |
+
"noUnusedParameters": true,
|
| 17 |
+
"noFallthroughCasesInSwitch": true,
|
| 18 |
+
"noUncheckedSideEffectImports": true
|
| 19 |
+
},
|
| 20 |
+
"include": ["src"]
|
| 21 |
+
}
|
tsconfig.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"files": [],
|
| 3 |
+
"references": [
|
| 4 |
+
{ "path": "./tsconfig.app.json" },
|
| 5 |
+
{ "path": "./tsconfig.node.json" }
|
| 6 |
+
]
|
| 7 |
+
}
|
tsconfig.node.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"lib": ["ES2023"],
|
| 5 |
+
"module": "ESNext",
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
"moduleResolution": "bundler",
|
| 8 |
+
"allowImportingTsExtensions": true,
|
| 9 |
+
"isolatedModules": true,
|
| 10 |
+
"moduleDetection": "force",
|
| 11 |
+
"noEmit": true,
|
| 12 |
+
"strict": true,
|
| 13 |
+
"noUnusedLocals": true,
|
| 14 |
+
"noUnusedParameters": true,
|
| 15 |
+
"noFallthroughCasesInSwitch": true,
|
| 16 |
+
"noUncheckedSideEffectImports": true
|
| 17 |
+
},
|
| 18 |
+
"include": ["vite.config.ts"]
|
| 19 |
+
}
|
vite.config.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "vite";
|
| 2 |
+
import react from "@vitejs/plugin-react";
|
| 3 |
+
import tailwindcss from "@tailwindcss/vite";
|
| 4 |
+
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [tailwindcss(), react()],
|
| 7 |
+
});
|