BryanBradfo commited on
Commit
576d07a
·
0 Parent(s):

Gemma 4 multimodal WebGPU detection Space

Browse files
.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

  • SHA256: 48a98d3e74cc62915a5012b0d167138d1c3299ad8f2a771e40cbeb22bf9d82a2
  • Pointer size: 131 Bytes
  • Size of remote file: 764 kB
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

  • SHA256: 48a98d3e74cc62915a5012b0d167138d1c3299ad8f2a771e40cbeb22bf9d82a2
  • Pointer size: 131 Bytes
  • Size of remote file: 764 kB
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
+ });