Spaces:
Sleeping
Decouple tracking from detection; size-relative motion; UI tuning
Browse filesEngine:
- Replace centroid SimpleTracker (tracking.py) with GreedyIoUTracker in
detector.py. Detection now always runs via predict(); IDs are assigned in a
separate IoU pass, so unmatched detections are kept (full recall) instead of
being dropped by a confirm-before-emit tracker.
- Dedup keeps the oldest track_id in an overlap group so identity no longer
flickers with per-frame confidence.
- Moving condition: foot-point + object-size-relative, smoothed half-window
displacement (min_displacement_ratio) replacing frame-% box-center
displacement, rejecting box jitter on stationary objects.
- server.py enables tracking only when a rule actually uses moving.
UI:
- Sample-interval slider default 0.25s, 0.25s steps.
- Drop "dog" from default/example classes; rebuild dist bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- DEMO.md +5 -4
- README.md +7 -5
- frontend/dist/assets/{index-CLWJR9Mi.js → index-CiwitQvv.js} +0 -0
- frontend/dist/assets/index-DbWTYGKW.css +1 -0
- frontend/dist/assets/index-DpGaJEG3.css +0 -1
- frontend/dist/index.html +2 -2
- frontend/src/lib/dashboard.tsx +16 -2
- frontend/src/lib/types.ts +81 -0
- frontend/src/modules/detector/DetectorPanel.tsx +5 -5
- frontend/src/modules/rules/RuleStudioPanel.tsx +434 -55
- server.py +37 -1
- tests/test_automation.py +42 -2
- tests/test_detector.py +169 -1
- tests/test_server.py +33 -1
- tests/test_tracking.py +0 -26
- tests/test_video.py +22 -4
- tiny_trigger/__init__.py +2 -1
- tiny_trigger/automation.py +33 -12
- tiny_trigger/detector.py +82 -8
- tiny_trigger/llm.py +2 -2
- tiny_trigger/tracking.py +0 -85
- tiny_trigger/video.py +64 -5
|
@@ -58,10 +58,11 @@ Recommended starting detector settings:
|
|
| 58 |
|
| 59 |
Rules automatically add their referenced labels to the detector class list.
|
| 60 |
Rules can use presence, count, near, far, moving, enter, exit, change, and
|
| 61 |
-
cooldown. Moving uses
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
| 65 |
|
| 66 |
## Prompts To Try
|
| 67 |
|
|
|
|
| 58 |
|
| 59 |
Rules automatically add their referenced labels to the detector class list.
|
| 60 |
Rules can use presence, count, near, far, moving, enter, exit, change, and
|
| 61 |
+
cooldown. Moving uses detector-provided track IDs across sampled frames; YOLOE
|
| 62 |
+
uses Ultralytics ByteTrack only when active rules include motion. It requires at
|
| 63 |
+
least three tracked observations and tolerates one missed sampled frame by
|
| 64 |
+
default. Speed, direction, long-gap re-identification, and trajectory paths are
|
| 65 |
+
not supported yet.
|
| 66 |
|
| 67 |
## Prompts To Try
|
| 68 |
|
|
@@ -88,11 +88,13 @@ rules:
|
|
| 88 |
|
| 89 |
Initial video conditions include presence, count, near, far, and moving. Near/far
|
| 90 |
use the minimum horizontal/vertical gap between detection boxes in normalized
|
| 91 |
-
frame percent. Moving uses
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
| 96 |
re-identification, or trajectory path rules.
|
| 97 |
|
| 98 |
```yaml
|
|
|
|
| 88 |
|
| 89 |
Initial video conditions include presence, count, near, far, and moving. Near/far
|
| 90 |
use the minimum horizontal/vertical gap between detection boxes in normalized
|
| 91 |
+
frame percent. Moving uses detector-provided track IDs across sampled frames;
|
| 92 |
+
YOLOE enables Ultralytics ByteTrack only when active rules include a moving
|
| 93 |
+
condition, so presence/near/count rules keep the higher-recall prediction path.
|
| 94 |
+
Moving tolerates one missed sampled frame by default to avoid repeat alerts from
|
| 95 |
+
detector flicker. Gates include enabled state and cooldown. Triggers can fire
|
| 96 |
+
while a condition is true, when it becomes true, when it becomes false, or on
|
| 97 |
+
either change. Tiny Trigger does not yet support speed, direction, long-gap
|
| 98 |
re-identification, or trajectory path rules.
|
| 99 |
|
| 100 |
```yaml
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */@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-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--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-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction: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-outline-style:solid;--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-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--color-black:#000;--color-white:#fff;--spacing:.25rem;--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: 1.5 ;--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-relaxed:1.625;--animate-spin:spin 1s linear infinite;--blur-sm:8px;--blur-xl:24px;--aspect-video:16 / 9;--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);--color-background:var(--background);--color-foreground:var(--foreground);--color-primary:var(--primary);--color-border:var(--border);--font-display:var(--font-display)}}@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%;-moz-tab-size:4;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;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]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--color-border)}html{-webkit-font-smoothing:antialiased;text-rendering:optimizelegibility}body{background-color:var(--color-background);min-height:100vh;color:var(--color-foreground);font-family:var(--font-sans);font-feature-settings:"ss01","cv01","tnum";background-image:radial-gradient(90rem 60rem at 82% -10%,#c5ec4512,#0000 60%),radial-gradient(70rem 50rem at -10% 110%,#4cc8ee0f,#0000 55%),linear-gradient(90deg,#ffffff05 1px,#0000 1px),linear-gradient(#ffffff05 1px,#0000 1px);background-size:100% 100%,100% 100%,56px 56px,56px 56px;background-attachment:fixed;margin:0}::selection{background:#c5ec4547}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:#ffffff1a padding-box content-box;border:2px solid #0000;border-radius:999px}::-webkit-scrollbar-thumb:hover{background:#ffffff2e padding-box content-box}}@layer components;@layer utilities{.\@container{container-type:inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.not-sr-only{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.inset-x-0{inset-inline:calc(var(--spacing) * 0)}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.-top-0,.top-0{top:calc(var(--spacing) * 0)}.top-1\/2{top:50%}.bottom-0{bottom:calc(var(--spacing) * 0)}.left-0{left:calc(var(--spacing) * 0)}.left-1\/2{left:50%}.isolate{isolation:isolate}.isolation-auto{isolation:auto}.z-10{z-index:10}.z-30{z-index:30}.z-50{z-index:50}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.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-5{margin-bottom:calc(var(--spacing) * 5)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:calc(var(--spacing) * 1)}.block{display:block}.contents{display:contents}.flex{display:flex}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.aspect-video{aspect-ratio:var(--aspect-video)}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-full{width:100%;height:100%}.h-1{height:calc(var(--spacing) * 1)}.h-5{height:calc(var(--spacing) * 5)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-16{height:calc(var(--spacing) * 16)}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-28{max-height:calc(var(--spacing) * 28)}.max-h-72{max-height:calc(var(--spacing) * 72)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-20{min-height:calc(var(--spacing) * 20)}.min-h-24{min-height:calc(var(--spacing) * 24)}.min-h-64{min-height:calc(var(--spacing) * 64)}.min-h-screen{min-height:100vh}.w-0\.5{width:calc(var(--spacing) * .5)}.w-9{width:calc(var(--spacing) * 9)}.w-16{width:calc(var(--spacing) * 16)}.w-full{width:100%}.w-px{width:1px}.max-w-\[1500px\]{max-width:1500px}.min-w-0{min-width:calc(var(--spacing) * 0)}.flex-1{flex:1}.flex-\[2\]{flex:2}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.caption-bottom{caption-side:bottom}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-4{--tw-translate-x:calc(var(--spacing) * 4);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-none{translate:none}.scale-3d{scale:var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z)}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.animate-spin{animation:var(--animate-spin)}.cursor-pointer{cursor:pointer}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.scroll-mt-20{scroll-margin-top:calc(var(--spacing) * 20)}.scroll-mt-24{scroll-margin-top:calc(var(--spacing) * 24)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}: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-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-reverse>:not(:last-child)){--tw-space-y-reverse:1}.gap-x-4{column-gap:calc(var(--spacing) * 4)}:where(.space-x-reverse>:not(:last-child)){--tw-space-x-reverse:1}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-reverse>:not(:last-child)){--tw-divide-y-reverse:1}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.\!rounded-lg{border-radius:var(--radius)!important}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-s{border-start-start-radius:.25rem;border-end-start-radius:.25rem}.rounded-ss{border-start-start-radius:.25rem}.rounded-e{border-start-end-radius:.25rem;border-end-end-radius:.25rem}.rounded-se{border-start-end-radius:.25rem}.rounded-ee{border-end-end-radius:.25rem}.rounded-es{border-end-start-radius:.25rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl{border-top-left-radius:.25rem}.rounded-tl-md{border-top-left-radius:calc(var(--radius) - 2px)}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr{border-top-right-radius:.25rem}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-br{border-bottom-right-radius:.25rem}.rounded-br-md{border-bottom-right-radius:calc(var(--radius) - 2px)}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-s{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.border-e{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.border-bs{border-block-start-style:var(--tw-border-style);border-block-start-width:1px}.border-be{border-block-end-style:var(--tw-border-style);border-block-end-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-border{border-color:var(--border)!important}.border-border,.border-border\/60{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/60{border-color:color-mix(in oklab,var(--border) 60%,transparent)}}.border-destructive\/30{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/30{border-color:color-mix(in oklab,var(--destructive) 30%,transparent)}}.border-destructive\/40{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/40{border-color:color-mix(in oklab,var(--destructive) 40%,transparent)}}.border-info\/40{border-color:var(--info)}@supports (color:color-mix(in lab,red,red)){.border-info\/40{border-color:color-mix(in oklab,var(--info) 40%,transparent)}}.border-input{border-color:var(--input)}.border-primary,.border-primary\/40{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/40{border-color:color-mix(in oklab,var(--primary) 40%,transparent)}}.border-primary\/50{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/50{border-color:color-mix(in oklab,var(--primary) 50%,transparent)}}.border-primary\/60{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/60{border-color:color-mix(in oklab,var(--primary) 60%,transparent)}}.border-warning\/30{border-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.border-warning\/30{border-color:color-mix(in oklab,var(--warning) 30%,transparent)}}.border-warning\/40{border-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.border-warning\/40{border-color:color-mix(in oklab,var(--warning) 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-popover{background-color:var(--popover)!important}.\!bg-primary{background-color:var(--primary)!important}.bg-background,.bg-background\/70{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/70{background-color:color-mix(in oklab,var(--background) 70%,transparent)}}.bg-black{background-color:var(--color-black)}.bg-black\/20{background-color:#0003}@supports (color:color-mix(in lab,red,red)){.bg-black\/20{background-color:color-mix(in oklab,var(--color-black) 20%,transparent)}}.bg-black\/30{background-color:#0000004d}@supports (color:color-mix(in lab,red,red)){.bg-black\/30{background-color:color-mix(in oklab,var(--color-black) 30%,transparent)}}.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\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,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-border{background-color:var(--border)}.bg-card\/40{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/40{background-color:color-mix(in oklab,var(--card) 40%,transparent)}}.bg-card\/80{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/80{background-color:color-mix(in oklab,var(--card) 80%,transparent)}}.bg-destructive\/5{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/5{background-color:color-mix(in oklab,var(--destructive) 5%,transparent)}}.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/10{background-color:color-mix(in oklab,var(--destructive) 10%,transparent)}}.bg-destructive\/15{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/15{background-color:color-mix(in oklab,var(--destructive) 15%,transparent)}}.bg-info\/10{background-color:var(--info)}@supports (color:color-mix(in lab,red,red)){.bg-info\/10{background-color:color-mix(in oklab,var(--info) 10%,transparent)}}.bg-muted-foreground,.bg-muted-foreground\/40{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-muted-foreground\/40{background-color:color-mix(in oklab,var(--muted-foreground) 40%,transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary,.bg-primary\/5{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/5{background-color:color-mix(in oklab,var(--primary) 5%,transparent)}}.bg-primary\/10{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/10{background-color:color-mix(in oklab,var(--primary) 10%,transparent)}}.bg-primary\/30{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/30{background-color:color-mix(in oklab,var(--primary) 30%,transparent)}}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.bg-warning\/5{background-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.bg-warning\/5{background-color:color-mix(in oklab,var(--warning) 5%,transparent)}}.bg-warning\/10{background-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.bg-warning\/10{background-color:color-mix(in oklab,var(--warning) 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-primary\/90{--tw-gradient-from:var(--primary)}@supports (color:color-mix(in lab,red,red)){.from-primary\/90{--tw-gradient-from:color-mix(in oklab, var(--primary) 90%, transparent)}}.from-primary\/90{--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-primary\/70{--tw-gradient-to:var(--primary)}@supports (color:color-mix(in lab,red,red)){.to-primary\/70{--tw-gradient-to:color-mix(in oklab, var(--primary) 70%, transparent)}}.to-primary\/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))}.bg-repeat{background-repeat:repeat}.mask-no-clip{-webkit-mask-clip:no-clip;mask-clip:no-clip}.mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.object-contain{object-fit:contain}.p-1{padding:calc(var(--spacing) * 1)}.p-3{padding:calc(var(--spacing) * 3)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.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-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-7{padding-top:calc(var(--spacing) * 7)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pl-9{padding-left:calc(var(--spacing) * 9)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.\!font-sans{font-family:var(--font-sans)!important}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.5rem\]{font-size:.5rem}.text-\[0\.95rem\]{font-size:.95rem}.text-\[0\.625rem\]{font-size:.625rem}.text-\[0\.6875rem\]{font-size:.6875rem}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-wrap{text-wrap:wrap}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-foreground{color:var(--foreground)!important}.\!text-muted-foreground{color:var(--muted-foreground)!important}.\!text-primary-foreground{color:var(--primary-foreground)!important}.text-destructive,.text-destructive\/90{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/90{color:color-mix(in oklab,var(--destructive) 90%,transparent)}}.text-foreground{color:var(--foreground)}.text-info{color:var(--info)}.text-muted-foreground,.text-muted-foreground\/50{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/50{color:color-mix(in oklab,var(--muted-foreground) 50%,transparent)}}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-primary\/70{color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.text-primary\/70{color:color-mix(in oklab,var(--primary) 70%,transparent)}}.text-secondary-foreground{color:var(--secondary-foreground)}.text-warning{color:var(--warning)}.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)}}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.diagonal-fractions{--tw-numeric-fraction:diagonal-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.lining-nums{--tw-numeric-figure:lining-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.oldstyle-nums{--tw-numeric-figure:oldstyle-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.proportional-nums{--tw-numeric-spacing:proportional-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.slashed-zero{--tw-slashed-zero:slashed-zero;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.stacked-fractions{--tw-numeric-fraction:stacked-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.normal-nums{font-variant-numeric:normal}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.opacity-40{opacity:.4}.opacity-100{opacity:1}.opacity-\[0\.07\]{opacity:.07}.\!shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040)!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}.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-\[0_0_0_1px_oklch\(0\.885_0\.19_122_\/_0\.4\)\,0_8px_24px_-12px_oklch\(0\.885_0\.19_122_\/_0\.6\)\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,oklch(88.5% .19 122/.4)), 0 8px 24px -12px var(--tw-shadow-color,oklch(88.5% .19 122/.6));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_3px_oklch\(0\.885_0\.19_122_\/_0\.2\)\]{--tw-shadow:0 0 0 3px var(--tw-shadow-color,oklch(88.5% .19 122/.2));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_1px_0_0_oklch\(1_0_0_\/_0\.04\)_inset\,0_24px_48px_-32px_oklch\(0_0_0_\/_0\.8\)\]{--tw-shadow:0 1px 0 0 var(--tw-shadow-color,oklch(100% 0 0/.04)) inset, 0 24px 48px -32px var(--tw-shadow-color,oklch(0% 0 0/.8));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_8px_24px_-12px_oklch\(0\.885_0\.19_122_\/_0\.8\)\]{--tw-shadow:0 8px 24px -12px var(--tw-shadow-color,oklch(88.5% .19 122/.8));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px 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)}.inset-ring{--tw-inset-ring-shadow:inset 0 0 0 1px var(--tw-inset-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)}.ring-offset-background{--tw-ring-offset-color:var(--background)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.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{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);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,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xl{--tw-backdrop-blur:blur(var(--blur-xl));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-grayscale{--tw-backdrop-grayscale:grayscale(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-invert{--tw-backdrop-invert:invert(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-sepia{--tw-backdrop-sepia:sepia(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.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))}.fade-in-0{--tw-enter-opacity:0}.select-none{-webkit-user-select:none;user-select:none}.zoom-in-95{--tw-enter-scale:.95}.\[writing-mode\:vertical-rl\]{writing-mode:vertical-rl}:where(.divide-x-reverse>:not(:last-child)){--tw-divide-x-reverse:1}.paused{animation-play-state:paused}.ring-inset{--tw-ring-inset:inset}.running{animation-play-state:running}.zoom-in{--tw-enter-scale:0}.zoom-out{--tw-exit-scale:0}@media(hover:hover){.group-hover\:text-foreground:is(:where(.group):hover *){color:var(--foreground)}}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.placeholder\:text-muted-foreground\/60::placeholder{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.placeholder\:text-muted-foreground\/60::placeholder{color:color-mix(in oklab,var(--muted-foreground) 60%,transparent)}}@media(hover:hover){.hover\:scale-110:hover{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:border-white\/15:hover{border-color:#ffffff26}@supports (color:color-mix(in lab,red,red)){.hover\:border-white\/15:hover{border-color:color-mix(in oklab,var(--color-white) 15%,transparent)}}.hover\:border-white\/20:hover{border-color:#fff3}@supports (color:color-mix(in lab,red,red)){.hover\:border-white\/20:hover{border-color:color-mix(in oklab,var(--color-white) 20%,transparent)}}.hover\:bg-accent:hover{background-color:var(--accent)}.hover\:bg-destructive\/25:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/25:hover{background-color:color-mix(in oklab,var(--destructive) 25%,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\:bg-white\/\[0\.02\]:hover{background-color:#ffffff05}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.02\]:hover{background-color:color-mix(in oklab,var(--color-white) 2%,transparent)}}.hover\:bg-white\/\[0\.025\]:hover{background-color:#ffffff06}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.025\]:hover{background-color:color-mix(in oklab,var(--color-white) 2.5%,transparent)}}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:brightness-110:hover{--tw-brightness:brightness(110%);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,)}}.focus-visible\:border-primary\/50:focus-visible{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:border-primary\/50:focus-visible{border-color:color-mix(in oklab,var(--primary) 50%,transparent)}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + 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-visible\:ring-ring:focus-visible{--tw-ring-color:var(--ring)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.active\:scale-\[0\.98\]:active{scale:.98}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.data-\[state\=active\]\:bg-secondary[data-state=active]{background-color:var(--secondary)}.data-\[state\=active\]\:text-foreground[data-state=active]{color:var(--foreground)}.data-\[state\=active\]\:shadow-sm[data-state=active]{--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)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}@media(min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}}@media(min-width:48rem){.md\:flex{display:flex}}@media(min-width:64rem){.lg\:col-span-4{grid-column:span 4/span 4}.lg\:col-span-5{grid-column:span 5/span 5}.lg\:col-span-6{grid-column:span 6/span 6}.lg\:col-span-7{grid-column:span 7/span 7}.lg\:col-span-8{grid-column:span 8/span 8}.lg\:col-span-12{grid-column:span 12/span 12}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}}.\[\&_svg\]\:size-4 svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.font-display{font-family:var(--font-display)}.text-balance{text-wrap:balance}.signal-dot{box-shadow:0 0 0 0 var(--color-primary);animation:1.8s cubic-bezier(.4,0,.2,1) infinite signal-pulse}@keyframes signal-pulse{0%{box-shadow:0 0 #c5ec4580}70%{box-shadow:0 0 0 8px #c5ec4500}to{box-shadow:0 0 #c5ec4500}}.reveal{opacity:0;animation:.55s cubic-bezier(.22,1,.36,1) forwards reveal-up}@keyframes reveal-up{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.hairline{background-image:linear-gradient(to right,transparent,var(--color-border),transparent)}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--radius:.625rem;--background:oklch(14.5% .006 264);--foreground:oklch(93% .004 264);--card:oklch(18.5% .007 264);--card-foreground:oklch(93% .004 264);--popover:oklch(20.5% .008 264);--popover-foreground:oklch(93% .004 264);--primary:oklch(88.5% .19 122);--primary-foreground:oklch(18% .03 130);--secondary:oklch(23.5% .008 264);--secondary-foreground:oklch(93% .004 264);--muted:oklch(23.5% .008 264);--muted-foreground:oklch(64% .012 264);--accent:oklch(23.5% .008 264);--accent-foreground:oklch(93% .004 264);--destructive:oklch(63.7% .21 25.3);--destructive-foreground:oklch(97% .01 20);--warning:oklch(79% .15 78);--warning-foreground:oklch(20% .03 78);--info:oklch(78% .12 222);--info-foreground:oklch(18% .03 240);--border:oklch(100% 0 0/.08);--input:oklch(100% 0 0/.12);--ring:oklch(88.5% .19 122/.55);--font-display:"Clash Display", ui-sans-serif, system-ui, sans-serif;--font-sans:"Hanken Grotesk", ui-sans-serif, system-ui, sans-serif;--font-mono:"JetBrains Mono", ui-monospace, monospace}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@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}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@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-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{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-outline-style{syntax:"*";inherits:false;initial-value:solid}@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-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0));filter:blur(var(--tw-exit-blur,0))}}
|
|
@@ -1 +0,0 @@
|
|
| 1 |
-
/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */@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-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--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-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction: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-outline-style:solid;--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-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--color-black:#000;--color-white:#fff;--spacing:.25rem;--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: 1.5 ;--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-relaxed:1.625;--animate-spin:spin 1s linear infinite;--blur-sm:8px;--blur-xl:24px;--aspect-video:16 / 9;--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);--color-background:var(--background);--color-foreground:var(--foreground);--color-primary:var(--primary);--color-border:var(--border);--font-display:var(--font-display)}}@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%;-moz-tab-size:4;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;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]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--color-border)}html{-webkit-font-smoothing:antialiased;text-rendering:optimizelegibility}body{background-color:var(--color-background);min-height:100vh;color:var(--color-foreground);font-family:var(--font-sans);font-feature-settings:"ss01","cv01","tnum";background-image:radial-gradient(90rem 60rem at 82% -10%,#c5ec4512,#0000 60%),radial-gradient(70rem 50rem at -10% 110%,#4cc8ee0f,#0000 55%),linear-gradient(90deg,#ffffff05 1px,#0000 1px),linear-gradient(#ffffff05 1px,#0000 1px);background-size:100% 100%,100% 100%,56px 56px,56px 56px;background-attachment:fixed;margin:0}::selection{background:#c5ec4547}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:#ffffff1a padding-box content-box;border:2px solid #0000;border-radius:999px}::-webkit-scrollbar-thumb:hover{background:#ffffff2e padding-box content-box}}@layer components;@layer utilities{.\@container{container-type:inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.not-sr-only{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.inset-x-0{inset-inline:calc(var(--spacing) * 0)}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.-top-0,.top-0{top:calc(var(--spacing) * 0)}.top-1\/2{top:50%}.bottom-0{bottom:calc(var(--spacing) * 0)}.left-0{left:calc(var(--spacing) * 0)}.left-1\/2{left:50%}.isolate{isolation:isolate}.isolation-auto{isolation:auto}.z-10{z-index:10}.z-30{z-index:30}.z-50{z-index:50}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.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-5{margin-bottom:calc(var(--spacing) * 5)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:calc(var(--spacing) * 1)}.block{display:block}.contents{display:contents}.flex{display:flex}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.aspect-video{aspect-ratio:var(--aspect-video)}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-full{width:100%;height:100%}.h-1{height:calc(var(--spacing) * 1)}.h-5{height:calc(var(--spacing) * 5)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-16{height:calc(var(--spacing) * 16)}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-28{max-height:calc(var(--spacing) * 28)}.max-h-72{max-height:calc(var(--spacing) * 72)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-20{min-height:calc(var(--spacing) * 20)}.min-h-24{min-height:calc(var(--spacing) * 24)}.min-h-64{min-height:calc(var(--spacing) * 64)}.min-h-screen{min-height:100vh}.w-0\.5{width:calc(var(--spacing) * .5)}.w-9{width:calc(var(--spacing) * 9)}.w-16{width:calc(var(--spacing) * 16)}.w-full{width:100%}.w-px{width:1px}.max-w-\[1500px\]{max-width:1500px}.min-w-0{min-width:calc(var(--spacing) * 0)}.flex-1{flex:1}.flex-\[2\]{flex:2}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.caption-bottom{caption-side:bottom}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-4{--tw-translate-x:calc(var(--spacing) * 4);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-none{translate:none}.scale-3d{scale:var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z)}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.animate-spin{animation:var(--animate-spin)}.cursor-pointer{cursor:pointer}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.scroll-mt-20{scroll-margin-top:calc(var(--spacing) * 20)}.scroll-mt-24{scroll-margin-top:calc(var(--spacing) * 24)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}: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-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-reverse>:not(:last-child)){--tw-space-y-reverse:1}.gap-x-4{column-gap:calc(var(--spacing) * 4)}:where(.space-x-reverse>:not(:last-child)){--tw-space-x-reverse:1}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-reverse>:not(:last-child)){--tw-divide-y-reverse:1}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.\!rounded-lg{border-radius:var(--radius)!important}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-s{border-start-start-radius:.25rem;border-end-start-radius:.25rem}.rounded-ss{border-start-start-radius:.25rem}.rounded-e{border-start-end-radius:.25rem;border-end-end-radius:.25rem}.rounded-se{border-start-end-radius:.25rem}.rounded-ee{border-end-end-radius:.25rem}.rounded-es{border-end-start-radius:.25rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl{border-top-left-radius:.25rem}.rounded-tl-md{border-top-left-radius:calc(var(--radius) - 2px)}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr{border-top-right-radius:.25rem}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-br{border-bottom-right-radius:.25rem}.rounded-br-md{border-bottom-right-radius:calc(var(--radius) - 2px)}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-s{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.border-e{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.border-bs{border-block-start-style:var(--tw-border-style);border-block-start-width:1px}.border-be{border-block-end-style:var(--tw-border-style);border-block-end-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-border{border-color:var(--border)!important}.border-border,.border-border\/60{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/60{border-color:color-mix(in oklab,var(--border) 60%,transparent)}}.border-destructive\/30{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/30{border-color:color-mix(in oklab,var(--destructive) 30%,transparent)}}.border-destructive\/40{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/40{border-color:color-mix(in oklab,var(--destructive) 40%,transparent)}}.border-info\/40{border-color:var(--info)}@supports (color:color-mix(in lab,red,red)){.border-info\/40{border-color:color-mix(in oklab,var(--info) 40%,transparent)}}.border-input{border-color:var(--input)}.border-primary,.border-primary\/40{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/40{border-color:color-mix(in oklab,var(--primary) 40%,transparent)}}.border-primary\/50{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/50{border-color:color-mix(in oklab,var(--primary) 50%,transparent)}}.border-primary\/60{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/60{border-color:color-mix(in oklab,var(--primary) 60%,transparent)}}.border-warning\/30{border-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.border-warning\/30{border-color:color-mix(in oklab,var(--warning) 30%,transparent)}}.border-warning\/40{border-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.border-warning\/40{border-color:color-mix(in oklab,var(--warning) 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-popover{background-color:var(--popover)!important}.\!bg-primary{background-color:var(--primary)!important}.bg-background,.bg-background\/70{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/70{background-color:color-mix(in oklab,var(--background) 70%,transparent)}}.bg-black{background-color:var(--color-black)}.bg-black\/20{background-color:#0003}@supports (color:color-mix(in lab,red,red)){.bg-black\/20{background-color:color-mix(in oklab,var(--color-black) 20%,transparent)}}.bg-black\/30{background-color:#0000004d}@supports (color:color-mix(in lab,red,red)){.bg-black\/30{background-color:color-mix(in oklab,var(--color-black) 30%,transparent)}}.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\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,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-border{background-color:var(--border)}.bg-card\/40{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/40{background-color:color-mix(in oklab,var(--card) 40%,transparent)}}.bg-card\/80{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/80{background-color:color-mix(in oklab,var(--card) 80%,transparent)}}.bg-destructive\/5{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/5{background-color:color-mix(in oklab,var(--destructive) 5%,transparent)}}.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/10{background-color:color-mix(in oklab,var(--destructive) 10%,transparent)}}.bg-destructive\/15{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/15{background-color:color-mix(in oklab,var(--destructive) 15%,transparent)}}.bg-info\/10{background-color:var(--info)}@supports (color:color-mix(in lab,red,red)){.bg-info\/10{background-color:color-mix(in oklab,var(--info) 10%,transparent)}}.bg-muted-foreground,.bg-muted-foreground\/40{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-muted-foreground\/40{background-color:color-mix(in oklab,var(--muted-foreground) 40%,transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary,.bg-primary\/5{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/5{background-color:color-mix(in oklab,var(--primary) 5%,transparent)}}.bg-primary\/10{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/10{background-color:color-mix(in oklab,var(--primary) 10%,transparent)}}.bg-primary\/30{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/30{background-color:color-mix(in oklab,var(--primary) 30%,transparent)}}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.bg-warning\/5{background-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.bg-warning\/5{background-color:color-mix(in oklab,var(--warning) 5%,transparent)}}.bg-warning\/10{background-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.bg-warning\/10{background-color:color-mix(in oklab,var(--warning) 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-primary\/90{--tw-gradient-from:var(--primary)}@supports (color:color-mix(in lab,red,red)){.from-primary\/90{--tw-gradient-from:color-mix(in oklab, var(--primary) 90%, transparent)}}.from-primary\/90{--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-primary\/70{--tw-gradient-to:var(--primary)}@supports (color:color-mix(in lab,red,red)){.to-primary\/70{--tw-gradient-to:color-mix(in oklab, var(--primary) 70%, transparent)}}.to-primary\/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))}.bg-repeat{background-repeat:repeat}.mask-no-clip{-webkit-mask-clip:no-clip;mask-clip:no-clip}.mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.object-contain{object-fit:contain}.p-1{padding:calc(var(--spacing) * 1)}.p-3{padding:calc(var(--spacing) * 3)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.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-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-7{padding-top:calc(var(--spacing) * 7)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pl-3\.5{padding-left:calc(var(--spacing) * 3.5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.\!font-sans{font-family:var(--font-sans)!important}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.5rem\]{font-size:.5rem}.text-\[0\.95rem\]{font-size:.95rem}.text-\[0\.625rem\]{font-size:.625rem}.text-\[0\.6875rem\]{font-size:.6875rem}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-wrap{text-wrap:wrap}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-foreground{color:var(--foreground)!important}.\!text-muted-foreground{color:var(--muted-foreground)!important}.\!text-primary-foreground{color:var(--primary-foreground)!important}.text-destructive,.text-destructive\/90{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/90{color:color-mix(in oklab,var(--destructive) 90%,transparent)}}.text-foreground{color:var(--foreground)}.text-info{color:var(--info)}.text-muted-foreground,.text-muted-foreground\/50{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/50{color:color-mix(in oklab,var(--muted-foreground) 50%,transparent)}}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-primary\/70{color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.text-primary\/70{color:color-mix(in oklab,var(--primary) 70%,transparent)}}.text-secondary-foreground{color:var(--secondary-foreground)}.text-warning{color:var(--warning)}.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)}}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.diagonal-fractions{--tw-numeric-fraction:diagonal-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.lining-nums{--tw-numeric-figure:lining-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.oldstyle-nums{--tw-numeric-figure:oldstyle-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.proportional-nums{--tw-numeric-spacing:proportional-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.slashed-zero{--tw-slashed-zero:slashed-zero;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.stacked-fractions{--tw-numeric-fraction:stacked-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.normal-nums{font-variant-numeric:normal}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.opacity-40{opacity:.4}.opacity-100{opacity:1}.opacity-\[0\.07\]{opacity:.07}.\!shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040)!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}.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-\[0_0_0_1px_oklch\(0\.885_0\.19_122_\/_0\.4\)\,0_8px_24px_-12px_oklch\(0\.885_0\.19_122_\/_0\.6\)\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,oklch(88.5% .19 122/.4)), 0 8px 24px -12px var(--tw-shadow-color,oklch(88.5% .19 122/.6));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_3px_oklch\(0\.885_0\.19_122_\/_0\.2\)\]{--tw-shadow:0 0 0 3px var(--tw-shadow-color,oklch(88.5% .19 122/.2));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_1px_0_0_oklch\(1_0_0_\/_0\.04\)_inset\,0_24px_48px_-32px_oklch\(0_0_0_\/_0\.8\)\]{--tw-shadow:0 1px 0 0 var(--tw-shadow-color,oklch(100% 0 0/.04)) inset, 0 24px 48px -32px var(--tw-shadow-color,oklch(0% 0 0/.8));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_8px_24px_-12px_oklch\(0\.885_0\.19_122_\/_0\.8\)\]{--tw-shadow:0 8px 24px -12px var(--tw-shadow-color,oklch(88.5% .19 122/.8));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px 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)}.inset-ring{--tw-inset-ring-shadow:inset 0 0 0 1px var(--tw-inset-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)}.ring-offset-background{--tw-ring-offset-color:var(--background)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.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{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);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,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xl{--tw-backdrop-blur:blur(var(--blur-xl));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-grayscale{--tw-backdrop-grayscale:grayscale(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-invert{--tw-backdrop-invert:invert(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-sepia{--tw-backdrop-sepia:sepia(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.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))}.fade-in-0{--tw-enter-opacity:0}.select-none{-webkit-user-select:none;user-select:none}.zoom-in-95{--tw-enter-scale:.95}.\[writing-mode\:vertical-rl\]{writing-mode:vertical-rl}:where(.divide-x-reverse>:not(:last-child)){--tw-divide-x-reverse:1}.paused{animation-play-state:paused}.ring-inset{--tw-ring-inset:inset}.running{animation-play-state:running}.zoom-in{--tw-enter-scale:0}.zoom-out{--tw-exit-scale:0}@media(hover:hover){.group-hover\:text-foreground:is(:where(.group):hover *){color:var(--foreground)}}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.placeholder\:text-muted-foreground\/60::placeholder{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.placeholder\:text-muted-foreground\/60::placeholder{color:color-mix(in oklab,var(--muted-foreground) 60%,transparent)}}@media(hover:hover){.hover\:scale-110:hover{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:border-white\/15:hover{border-color:#ffffff26}@supports (color:color-mix(in lab,red,red)){.hover\:border-white\/15:hover{border-color:color-mix(in oklab,var(--color-white) 15%,transparent)}}.hover\:border-white\/20:hover{border-color:#fff3}@supports (color:color-mix(in lab,red,red)){.hover\:border-white\/20:hover{border-color:color-mix(in oklab,var(--color-white) 20%,transparent)}}.hover\:bg-accent:hover{background-color:var(--accent)}.hover\:bg-destructive\/25:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/25:hover{background-color:color-mix(in oklab,var(--destructive) 25%,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\:bg-white\/\[0\.02\]:hover{background-color:#ffffff05}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.02\]:hover{background-color:color-mix(in oklab,var(--color-white) 2%,transparent)}}.hover\:bg-white\/\[0\.025\]:hover{background-color:#ffffff06}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.025\]:hover{background-color:color-mix(in oklab,var(--color-white) 2.5%,transparent)}}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:brightness-110:hover{--tw-brightness:brightness(110%);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,)}}.focus-visible\:border-primary\/50:focus-visible{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:border-primary\/50:focus-visible{border-color:color-mix(in oklab,var(--primary) 50%,transparent)}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + 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-visible\:ring-ring:focus-visible{--tw-ring-color:var(--ring)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.active\:scale-\[0\.98\]:active{scale:.98}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.data-\[state\=active\]\:bg-secondary[data-state=active]{background-color:var(--secondary)}.data-\[state\=active\]\:text-foreground[data-state=active]{color:var(--foreground)}.data-\[state\=active\]\:shadow-sm[data-state=active]{--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)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}@media(min-width:40rem){.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}}@media(min-width:48rem){.md\:flex{display:flex}}@media(min-width:64rem){.lg\:col-span-4{grid-column:span 4/span 4}.lg\:col-span-5{grid-column:span 5/span 5}.lg\:col-span-6{grid-column:span 6/span 6}.lg\:col-span-7{grid-column:span 7/span 7}.lg\:col-span-8{grid-column:span 8/span 8}.lg\:col-span-12{grid-column:span 12/span 12}.lg\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}}.\[\&_svg\]\:size-4 svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.font-display{font-family:var(--font-display)}.text-balance{text-wrap:balance}.signal-dot{box-shadow:0 0 0 0 var(--color-primary);animation:1.8s cubic-bezier(.4,0,.2,1) infinite signal-pulse}@keyframes signal-pulse{0%{box-shadow:0 0 #c5ec4580}70%{box-shadow:0 0 0 8px #c5ec4500}to{box-shadow:0 0 #c5ec4500}}.reveal{opacity:0;animation:.55s cubic-bezier(.22,1,.36,1) forwards reveal-up}@keyframes reveal-up{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.hairline{background-image:linear-gradient(to right,transparent,var(--color-border),transparent)}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--radius:.625rem;--background:oklch(14.5% .006 264);--foreground:oklch(93% .004 264);--card:oklch(18.5% .007 264);--card-foreground:oklch(93% .004 264);--popover:oklch(20.5% .008 264);--popover-foreground:oklch(93% .004 264);--primary:oklch(88.5% .19 122);--primary-foreground:oklch(18% .03 130);--secondary:oklch(23.5% .008 264);--secondary-foreground:oklch(93% .004 264);--muted:oklch(23.5% .008 264);--muted-foreground:oklch(64% .012 264);--accent:oklch(23.5% .008 264);--accent-foreground:oklch(93% .004 264);--destructive:oklch(63.7% .21 25.3);--destructive-foreground:oklch(97% .01 20);--warning:oklch(79% .15 78);--warning-foreground:oklch(20% .03 78);--info:oklch(78% .12 222);--info-foreground:oklch(18% .03 240);--border:oklch(100% 0 0/.08);--input:oklch(100% 0 0/.12);--ring:oklch(88.5% .19 122/.55);--font-display:"Clash Display", ui-sans-serif, system-ui, sans-serif;--font-sans:"Hanken Grotesk", ui-sans-serif, system-ui, sans-serif;--font-mono:"JetBrains Mono", ui-monospace, monospace}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@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}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@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-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{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-outline-style{syntax:"*";inherits:false;initial-value:solid}@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-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0));filter:blur(var(--tw-exit-blur,0))}}
|
|
|
|
|
|
|
@@ -16,8 +16,8 @@
|
|
| 16 |
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
| 17 |
rel="stylesheet"
|
| 18 |
/>
|
| 19 |
-
<script type="module" crossorigin src="/assets/index-
|
| 20 |
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
| 21 |
</head>
|
| 22 |
<body>
|
| 23 |
<div id="root"></div>
|
|
|
|
| 16 |
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
| 17 |
rel="stylesheet"
|
| 18 |
/>
|
| 19 |
+
<script type="module" crossorigin src="/assets/index-CiwitQvv.js"></script>
|
| 20 |
+
<link rel="stylesheet" crossorigin href="/assets/index-DbWTYGKW.css">
|
| 21 |
</head>
|
| 22 |
<body>
|
| 23 |
<div id="root"></div>
|
|
@@ -19,7 +19,7 @@ import type {
|
|
| 19 |
ValidationResult,
|
| 20 |
} from "./types"
|
| 21 |
|
| 22 |
-
const DEFAULT_CLASSES = "person, cat,
|
| 23 |
const DETECTOR_MODEL_VALUES = new Set([
|
| 24 |
"yoloe-26n-seg.pt",
|
| 25 |
"yoloe-26s-seg.pt",
|
|
@@ -46,7 +46,7 @@ const DEFAULT_RULES = `rules:
|
|
| 46 |
const DEFAULT_PARAMS: DetectParams = {
|
| 47 |
classes: DEFAULT_CLASSES,
|
| 48 |
confidence: 0.25,
|
| 49 |
-
sampleIntervalSec:
|
| 50 |
maxFrames: 120,
|
| 51 |
modelName: "yoloe-26s-seg.pt",
|
| 52 |
imageSize: 640,
|
|
@@ -70,6 +70,7 @@ interface DashboardState {
|
|
| 70 |
setParam: <K extends keyof DetectParams>(key: K, value: DetectParams[K]) => void
|
| 71 |
rulesText: string
|
| 72 |
setRulesText: (text: string) => void
|
|
|
|
| 73 |
videoFile: File | null
|
| 74 |
videoPreviewUrl: string | null
|
| 75 |
setVideo: (file: File | null) => void
|
|
@@ -121,6 +122,17 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
|
|
| 121 |
return nextValidation
|
| 122 |
}, [])
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
// Hydrate defaults from backend config + saved rules on mount.
|
| 125 |
useEffect(() => {
|
| 126 |
api
|
|
@@ -314,6 +326,7 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
|
|
| 314 |
setParam,
|
| 315 |
rulesText,
|
| 316 |
setRulesText,
|
|
|
|
| 317 |
videoFile,
|
| 318 |
videoPreviewUrl,
|
| 319 |
setVideo,
|
|
@@ -337,6 +350,7 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
|
|
| 337 |
params,
|
| 338 |
setParam,
|
| 339 |
rulesText,
|
|
|
|
| 340 |
videoFile,
|
| 341 |
videoPreviewUrl,
|
| 342 |
setVideo,
|
|
|
|
| 19 |
ValidationResult,
|
| 20 |
} from "./types"
|
| 21 |
|
| 22 |
+
const DEFAULT_CLASSES = "person, cat, car, chair, cup"
|
| 23 |
const DETECTOR_MODEL_VALUES = new Set([
|
| 24 |
"yoloe-26n-seg.pt",
|
| 25 |
"yoloe-26s-seg.pt",
|
|
|
|
| 46 |
const DEFAULT_PARAMS: DetectParams = {
|
| 47 |
classes: DEFAULT_CLASSES,
|
| 48 |
confidence: 0.25,
|
| 49 |
+
sampleIntervalSec: 0.25,
|
| 50 |
maxFrames: 120,
|
| 51 |
modelName: "yoloe-26s-seg.pt",
|
| 52 |
imageSize: 640,
|
|
|
|
| 70 |
setParam: <K extends keyof DetectParams>(key: K, value: DetectParams[K]) => void
|
| 71 |
rulesText: string
|
| 72 |
setRulesText: (text: string) => void
|
| 73 |
+
replaceRulesText: (text: string, persist?: boolean) => Promise<ValidationResult>
|
| 74 |
videoFile: File | null
|
| 75 |
videoPreviewUrl: string | null
|
| 76 |
setVideo: (file: File | null) => void
|
|
|
|
| 122 |
return nextValidation
|
| 123 |
}, [])
|
| 124 |
|
| 125 |
+
const replaceRulesText = useCallback(
|
| 126 |
+
async (nextRulesText: string, persist = false) => {
|
| 127 |
+
const nextValidation = await applyRulesText(nextRulesText)
|
| 128 |
+
if (persist && nextValidation.ok) {
|
| 129 |
+
await api.saveRules(nextRulesText)
|
| 130 |
+
}
|
| 131 |
+
return nextValidation
|
| 132 |
+
},
|
| 133 |
+
[applyRulesText],
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
// Hydrate defaults from backend config + saved rules on mount.
|
| 137 |
useEffect(() => {
|
| 138 |
api
|
|
|
|
| 326 |
setParam,
|
| 327 |
rulesText,
|
| 328 |
setRulesText,
|
| 329 |
+
replaceRulesText,
|
| 330 |
videoFile,
|
| 331 |
videoPreviewUrl,
|
| 332 |
setVideo,
|
|
|
|
| 350 |
params,
|
| 351 |
setParam,
|
| 352 |
rulesText,
|
| 353 |
+
replaceRulesText,
|
| 354 |
videoFile,
|
| 355 |
videoPreviewUrl,
|
| 356 |
setVideo,
|
|
@@ -50,10 +50,91 @@ export interface RuleSummary {
|
|
| 50 |
actions: { type: string; name: string }[]
|
| 51 |
}
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
export interface ValidationResult {
|
| 54 |
ok: boolean
|
| 55 |
error: string | null
|
| 56 |
rules: RuleSummary[]
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
export interface CompileResult {
|
|
|
|
| 50 |
actions: { type: string; name: string }[]
|
| 51 |
}
|
| 52 |
|
| 53 |
+
export type TriggerMode = "while" | "enter" | "exit" | "change"
|
| 54 |
+
export type ActionType = "simulate" | "webhook"
|
| 55 |
+
|
| 56 |
+
export interface PresentCondition {
|
| 57 |
+
label: string
|
| 58 |
+
min_count: number
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export interface CountCondition {
|
| 62 |
+
label: string
|
| 63 |
+
minimum: number
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export interface NearCondition {
|
| 67 |
+
a: string
|
| 68 |
+
b: string
|
| 69 |
+
max_gap_percent: number
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export interface FarCondition {
|
| 73 |
+
a: string
|
| 74 |
+
b: string
|
| 75 |
+
min_gap_percent: number
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
export interface MovingCondition {
|
| 79 |
+
label: string
|
| 80 |
+
min_displacement_ratio: number
|
| 81 |
+
window_frames: number
|
| 82 |
+
max_missing_frames: number
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
export interface CooldownCondition {
|
| 86 |
+
key: string | null
|
| 87 |
+
seconds: number | null
|
| 88 |
+
minutes: number | null
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
export interface ConditionBlock {
|
| 92 |
+
present?: PresentCondition | null
|
| 93 |
+
count?: CountCondition | null
|
| 94 |
+
near?: NearCondition | null
|
| 95 |
+
far?: FarCondition | null
|
| 96 |
+
moving?: MovingCondition | null
|
| 97 |
+
cooldown?: CooldownCondition | null
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
export interface WhenClause {
|
| 101 |
+
all: ConditionBlock[]
|
| 102 |
+
any: ConditionBlock[]
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
export interface ActionSpec {
|
| 106 |
+
type: ActionType
|
| 107 |
+
name: string
|
| 108 |
+
url: string | null
|
| 109 |
+
payload: Record<string, unknown>
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
export interface ActionSet {
|
| 113 |
+
enter: ActionSpec[]
|
| 114 |
+
exit: ActionSpec[]
|
| 115 |
+
while: ActionSpec[]
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
export interface AutomationRule {
|
| 119 |
+
name: string
|
| 120 |
+
when: WhenClause
|
| 121 |
+
trigger: { on: TriggerMode }
|
| 122 |
+
gate: {
|
| 123 |
+
enabled: boolean
|
| 124 |
+
cooldown: CooldownCondition | null
|
| 125 |
+
}
|
| 126 |
+
then: ActionSpec[] | ActionSet
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
export interface AutomationDocument {
|
| 130 |
+
rules: AutomationRule[]
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
export interface ValidationResult {
|
| 134 |
ok: boolean
|
| 135 |
error: string | null
|
| 136 |
rules: RuleSummary[]
|
| 137 |
+
document: AutomationDocument | null
|
| 138 |
}
|
| 139 |
|
| 140 |
export interface CompileResult {
|
|
@@ -114,7 +114,7 @@ export function DetectorPanel() {
|
|
| 114 |
value={params.classes}
|
| 115 |
onChange={(e) => setParam("classes", e.target.value)}
|
| 116 |
className="min-h-16 font-mono text-xs leading-relaxed"
|
| 117 |
-
placeholder="person, cat,
|
| 118 |
/>
|
| 119 |
</div>
|
| 120 |
|
|
@@ -147,11 +147,11 @@ export function DetectorPanel() {
|
|
| 147 |
<SliderRow
|
| 148 |
label="Sample interval"
|
| 149 |
value={params.sampleIntervalSec}
|
| 150 |
-
display={`${params.sampleIntervalSec.toFixed(
|
| 151 |
-
min={0.
|
| 152 |
max={5}
|
| 153 |
-
step={0.
|
| 154 |
-
onChange={(v) => setParam("sampleIntervalSec", Number(v.toFixed(
|
| 155 |
/>
|
| 156 |
<SliderRow
|
| 157 |
label="Max frames"
|
|
|
|
| 114 |
value={params.classes}
|
| 115 |
onChange={(e) => setParam("classes", e.target.value)}
|
| 116 |
className="min-h-16 font-mono text-xs leading-relaxed"
|
| 117 |
+
placeholder="person, cat, car, chair, cup…"
|
| 118 |
/>
|
| 119 |
</div>
|
| 120 |
|
|
|
|
| 147 |
<SliderRow
|
| 148 |
label="Sample interval"
|
| 149 |
value={params.sampleIntervalSec}
|
| 150 |
+
display={`${params.sampleIntervalSec.toFixed(2)}s`}
|
| 151 |
+
min={0.25}
|
| 152 |
max={5}
|
| 153 |
+
step={0.25}
|
| 154 |
+
onChange={(v) => setParam("sampleIntervalSec", Number(v.toFixed(2)))}
|
| 155 |
/>
|
| 156 |
<SliderRow
|
| 157 |
label="Max frames"
|
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { useState } from "react"
|
| 2 |
import {
|
| 3 |
CheckCircle2,
|
|
|
|
| 4 |
CircleSlash,
|
| 5 |
Loader2,
|
| 6 |
Save,
|
|
@@ -11,17 +12,26 @@ import {
|
|
| 11 |
} from "lucide-react"
|
| 12 |
import { useDashboard } from "@/lib/dashboard"
|
| 13 |
import { Button } from "@/components/ui/button"
|
|
|
|
| 14 |
import { Textarea } from "@/components/ui/textarea"
|
| 15 |
import { Badge } from "@/components/ui/badge"
|
| 16 |
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
| 17 |
import { Label } from "@/components/ui/label"
|
| 18 |
import { Switch } from "@/components/ui/switch"
|
| 19 |
import { cn } from "@/lib/utils"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
export function RuleStudioPanel() {
|
| 22 |
const {
|
| 23 |
rulesText,
|
| 24 |
setRulesText,
|
|
|
|
| 25 |
validate,
|
| 26 |
validation,
|
| 27 |
save,
|
|
@@ -35,6 +45,21 @@ export function RuleStudioPanel() {
|
|
| 35 |
"When a cat appears in the image, notify me.",
|
| 36 |
)
|
| 37 |
const [armedDelete, setArmedDelete] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
return (
|
| 40 |
<Tabs defaultValue="compose">
|
|
@@ -127,69 +152,97 @@ export function RuleStudioPanel() {
|
|
| 127 |
|
| 128 |
{/* Rules: parsed view */}
|
| 129 |
<TabsContent value="rules">
|
| 130 |
-
{validation?.ok &&
|
| 131 |
<div className="space-y-2">
|
| 132 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
<div
|
| 134 |
key={r.name}
|
| 135 |
-
className="
|
| 136 |
>
|
| 137 |
-
<
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
</div>
|
| 149 |
-
<div className="
|
| 150 |
-
{r.
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
</div>
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
className="rounded border border-border bg-black/30 px-1.5 py-0.5 font-mono text-[0.625rem] text-muted-foreground"
|
| 159 |
-
>
|
| 160 |
-
{label}
|
| 161 |
-
</span>
|
| 162 |
-
))}
|
| 163 |
-
</div>
|
| 164 |
-
)}
|
| 165 |
-
</div>
|
| 166 |
-
<div className="flex shrink-0 items-center gap-2">
|
| 167 |
-
<Label htmlFor={`enabled-${r.name}`} className="text-xs text-muted-foreground">
|
| 168 |
-
{r.enabled ? "enabled" : "disabled"}
|
| 169 |
-
</Label>
|
| 170 |
-
<Switch
|
| 171 |
-
id={`enabled-${r.name}`}
|
| 172 |
-
checked={r.enabled}
|
| 173 |
-
onCheckedChange={(checked) => setRuleEnabled(r.name, checked)}
|
| 174 |
/>
|
| 175 |
-
|
| 176 |
-
variant={armedDelete === r.name ? "destructive" : "secondary"}
|
| 177 |
-
size="sm"
|
| 178 |
-
onClick={() => {
|
| 179 |
-
if (armedDelete === r.name) {
|
| 180 |
-
void deleteRule(r.name)
|
| 181 |
-
setArmedDelete(null)
|
| 182 |
-
} else {
|
| 183 |
-
setArmedDelete(r.name)
|
| 184 |
-
}
|
| 185 |
-
}}
|
| 186 |
-
>
|
| 187 |
-
<Trash2 className="size-4" />
|
| 188 |
-
{armedDelete === r.name ? "Confirm" : "Delete"}
|
| 189 |
-
</Button>
|
| 190 |
-
</div>
|
| 191 |
</div>
|
| 192 |
-
))}
|
| 193 |
</div>
|
| 194 |
) : validation?.ok ? (
|
| 195 |
<div className="flex flex-col items-center gap-2 py-8 text-center text-muted-foreground">
|
|
@@ -209,3 +262,329 @@ export function RuleStudioPanel() {
|
|
| 209 |
</Tabs>
|
| 210 |
)
|
| 211 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { useState } from "react"
|
| 2 |
import {
|
| 3 |
CheckCircle2,
|
| 4 |
+
ChevronDown,
|
| 5 |
CircleSlash,
|
| 6 |
Loader2,
|
| 7 |
Save,
|
|
|
|
| 12 |
} from "lucide-react"
|
| 13 |
import { useDashboard } from "@/lib/dashboard"
|
| 14 |
import { Button } from "@/components/ui/button"
|
| 15 |
+
import { Input } from "@/components/ui/input"
|
| 16 |
import { Textarea } from "@/components/ui/textarea"
|
| 17 |
import { Badge } from "@/components/ui/badge"
|
| 18 |
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
| 19 |
import { Label } from "@/components/ui/label"
|
| 20 |
import { Switch } from "@/components/ui/switch"
|
| 21 |
import { cn } from "@/lib/utils"
|
| 22 |
+
import type {
|
| 23 |
+
ActionSpec,
|
| 24 |
+
AutomationDocument,
|
| 25 |
+
AutomationRule,
|
| 26 |
+
ConditionBlock,
|
| 27 |
+
CooldownCondition,
|
| 28 |
+
} from "@/lib/types"
|
| 29 |
|
| 30 |
export function RuleStudioPanel() {
|
| 31 |
const {
|
| 32 |
rulesText,
|
| 33 |
setRulesText,
|
| 34 |
+
replaceRulesText,
|
| 35 |
validate,
|
| 36 |
validation,
|
| 37 |
save,
|
|
|
|
| 45 |
"When a cat appears in the image, notify me.",
|
| 46 |
)
|
| 47 |
const [armedDelete, setArmedDelete] = useState<string | null>(null)
|
| 48 |
+
const [expandedRule, setExpandedRule] = useState<string | null>(null)
|
| 49 |
+
const document = validation?.ok ? validation.document : null
|
| 50 |
+
|
| 51 |
+
const applyDocument = (nextDocument: AutomationDocument) => {
|
| 52 |
+
void replaceRulesText(JSON.stringify(nextDocument, null, 2), true)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const updateRule = (ruleIndex: number, updater: (rule: AutomationRule) => void) => {
|
| 56 |
+
if (!document) return
|
| 57 |
+
const nextDocument = cloneDocument(document)
|
| 58 |
+
const rule = nextDocument.rules[ruleIndex]
|
| 59 |
+
if (!rule) return
|
| 60 |
+
updater(rule)
|
| 61 |
+
applyDocument(nextDocument)
|
| 62 |
+
}
|
| 63 |
|
| 64 |
return (
|
| 65 |
<Tabs defaultValue="compose">
|
|
|
|
| 152 |
|
| 153 |
{/* Rules: parsed view */}
|
| 154 |
<TabsContent value="rules">
|
| 155 |
+
{validation?.ok && document && document.rules.length > 0 ? (
|
| 156 |
<div className="space-y-2">
|
| 157 |
+
{document.rules.map((rule, ruleIndex) => {
|
| 158 |
+
const r = validation.rules.find((item) => item.name === rule.name) ?? {
|
| 159 |
+
name: rule.name,
|
| 160 |
+
enabled: rule.gate.enabled,
|
| 161 |
+
trigger: rule.trigger.on,
|
| 162 |
+
labels: [],
|
| 163 |
+
conditions: rule.when.all.length + rule.when.any.length,
|
| 164 |
+
actions: ruleActions(rule).map((action) => ({ type: action.type, name: action.name })),
|
| 165 |
+
}
|
| 166 |
+
const expanded = expandedRule === rule.name
|
| 167 |
+
return (
|
| 168 |
<div
|
| 169 |
key={r.name}
|
| 170 |
+
className="rounded-lg border border-border bg-black/20"
|
| 171 |
>
|
| 172 |
+
<button
|
| 173 |
+
type="button"
|
| 174 |
+
className="flex w-full flex-col gap-3 px-4 py-3 text-left sm:flex-row sm:items-center sm:justify-between"
|
| 175 |
+
onClick={() => setExpandedRule(expanded ? null : rule.name)}
|
| 176 |
+
>
|
| 177 |
+
<div className="min-w-0">
|
| 178 |
+
<div className="flex items-center gap-2">
|
| 179 |
+
<ChevronDown
|
| 180 |
+
className={cn(
|
| 181 |
+
"size-4 shrink-0 text-muted-foreground transition-transform",
|
| 182 |
+
expanded && "rotate-180 text-primary",
|
| 183 |
+
)}
|
| 184 |
+
/>
|
| 185 |
+
<span
|
| 186 |
+
className={cn(
|
| 187 |
+
"size-1.5 rounded-full",
|
| 188 |
+
r.enabled ? "bg-primary" : "bg-muted-foreground/40",
|
| 189 |
+
)}
|
| 190 |
+
/>
|
| 191 |
+
<span className="truncate font-mono text-sm text-foreground">{r.name}</span>
|
| 192 |
+
{r.name.startsWith("example-") && <Badge variant="default">example</Badge>}
|
| 193 |
+
<Badge variant="default">{r.trigger}</Badge>
|
| 194 |
+
</div>
|
| 195 |
+
<div className="mt-1 pl-9 text-xs text-muted-foreground">
|
| 196 |
+
{r.conditions} condition{r.conditions === 1 ? "" : "s"} →{" "}
|
| 197 |
+
{r.actions.map((a) => `${a.type}:${a.name}`).join(", ")}
|
| 198 |
+
</div>
|
| 199 |
+
{r.labels.length > 0 && (
|
| 200 |
+
<div className="mt-2 flex flex-wrap gap-1 pl-9">
|
| 201 |
+
{r.labels.map((label) => (
|
| 202 |
+
<span
|
| 203 |
+
key={label}
|
| 204 |
+
className="rounded border border-border bg-black/30 px-1.5 py-0.5 font-mono text-[0.625rem] text-muted-foreground"
|
| 205 |
+
>
|
| 206 |
+
{label}
|
| 207 |
+
</span>
|
| 208 |
+
))}
|
| 209 |
+
</div>
|
| 210 |
+
)}
|
| 211 |
</div>
|
| 212 |
+
<div className="flex shrink-0 items-center gap-2" onClick={(event) => event.stopPropagation()}>
|
| 213 |
+
<Label htmlFor={`enabled-${r.name}`} className="text-xs text-muted-foreground">
|
| 214 |
+
{r.enabled ? "enabled" : "disabled"}
|
| 215 |
+
</Label>
|
| 216 |
+
<Switch
|
| 217 |
+
id={`enabled-${r.name}`}
|
| 218 |
+
checked={r.enabled}
|
| 219 |
+
onCheckedChange={(checked) => setRuleEnabled(r.name, checked)}
|
| 220 |
+
/>
|
| 221 |
+
<Button
|
| 222 |
+
variant={armedDelete === r.name ? "destructive" : "secondary"}
|
| 223 |
+
size="sm"
|
| 224 |
+
onClick={() => {
|
| 225 |
+
if (armedDelete === r.name) {
|
| 226 |
+
void deleteRule(r.name)
|
| 227 |
+
setArmedDelete(null)
|
| 228 |
+
} else {
|
| 229 |
+
setArmedDelete(r.name)
|
| 230 |
+
}
|
| 231 |
+
}}
|
| 232 |
+
>
|
| 233 |
+
<Trash2 className="size-4" />
|
| 234 |
+
{armedDelete === r.name ? "Confirm" : "Delete"}
|
| 235 |
+
</Button>
|
| 236 |
</div>
|
| 237 |
+
</button>
|
| 238 |
+
{expanded && (
|
| 239 |
+
<RuleEditor
|
| 240 |
+
rule={rule}
|
| 241 |
+
onUpdate={(updater) => updateRule(ruleIndex, updater)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
/>
|
| 243 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
</div>
|
| 245 |
+
)})}
|
| 246 |
</div>
|
| 247 |
) : validation?.ok ? (
|
| 248 |
<div className="flex flex-col items-center gap-2 py-8 text-center text-muted-foreground">
|
|
|
|
| 262 |
</Tabs>
|
| 263 |
)
|
| 264 |
}
|
| 265 |
+
|
| 266 |
+
function RuleEditor({
|
| 267 |
+
rule,
|
| 268 |
+
onUpdate,
|
| 269 |
+
}: {
|
| 270 |
+
rule: AutomationRule
|
| 271 |
+
onUpdate: (updater: (rule: AutomationRule) => void) => void
|
| 272 |
+
}) {
|
| 273 |
+
return (
|
| 274 |
+
<div className="border-t border-border px-4 pb-4 pt-3">
|
| 275 |
+
<div className="grid gap-3 lg:grid-cols-2">
|
| 276 |
+
<div className="space-y-3">
|
| 277 |
+
<div className="flex items-center justify-between rounded-md border border-border bg-black/20 px-3 py-2">
|
| 278 |
+
<div>
|
| 279 |
+
<div className="text-xs font-medium text-foreground">Cooldown</div>
|
| 280 |
+
<div className="text-[0.6875rem] text-muted-foreground">Blocks repeated fires for this rule.</div>
|
| 281 |
+
</div>
|
| 282 |
+
<Switch
|
| 283 |
+
checked={Boolean(rule.gate.cooldown)}
|
| 284 |
+
onCheckedChange={(checked) =>
|
| 285 |
+
onUpdate((draft) => {
|
| 286 |
+
draft.gate.cooldown = checked ? { key: draft.name, seconds: null, minutes: 5 } : null
|
| 287 |
+
})
|
| 288 |
+
}
|
| 289 |
+
/>
|
| 290 |
+
</div>
|
| 291 |
+
{rule.gate.cooldown && (
|
| 292 |
+
<CooldownEditor
|
| 293 |
+
cooldown={rule.gate.cooldown}
|
| 294 |
+
onChange={(next) => onUpdate((draft) => { draft.gate.cooldown = next })}
|
| 295 |
+
/>
|
| 296 |
+
)}
|
| 297 |
+
<ConditionList
|
| 298 |
+
title="All conditions"
|
| 299 |
+
conditions={rule.when.all}
|
| 300 |
+
onUpdate={(conditionIndex, updater) =>
|
| 301 |
+
onUpdate((draft) => {
|
| 302 |
+
const condition = draft.when.all[conditionIndex]
|
| 303 |
+
if (condition) updater(condition)
|
| 304 |
+
})
|
| 305 |
+
}
|
| 306 |
+
/>
|
| 307 |
+
{rule.when.any.length > 0 && (
|
| 308 |
+
<ConditionList
|
| 309 |
+
title="Any conditions"
|
| 310 |
+
conditions={rule.when.any}
|
| 311 |
+
onUpdate={(conditionIndex, updater) =>
|
| 312 |
+
onUpdate((draft) => {
|
| 313 |
+
const condition = draft.when.any[conditionIndex]
|
| 314 |
+
if (condition) updater(condition)
|
| 315 |
+
})
|
| 316 |
+
}
|
| 317 |
+
/>
|
| 318 |
+
)}
|
| 319 |
+
</div>
|
| 320 |
+
<div className="space-y-3">
|
| 321 |
+
<ActionList
|
| 322 |
+
rule={rule}
|
| 323 |
+
onUpdate={(bucket, actionIndex, updater) =>
|
| 324 |
+
onUpdate((draft) => {
|
| 325 |
+
updateRuleAction(draft, bucket, actionIndex, updater)
|
| 326 |
+
})
|
| 327 |
+
}
|
| 328 |
+
/>
|
| 329 |
+
<div className="rounded-md border border-border bg-black/20 p-3 text-[0.6875rem] text-muted-foreground">
|
| 330 |
+
Global webhooks still act as the master safety switch. Per-rule webhook actions can carry their own URL.
|
| 331 |
+
</div>
|
| 332 |
+
</div>
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
)
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
function ConditionList({
|
| 339 |
+
title,
|
| 340 |
+
conditions,
|
| 341 |
+
onUpdate,
|
| 342 |
+
}: {
|
| 343 |
+
title: string
|
| 344 |
+
conditions: ConditionBlock[]
|
| 345 |
+
onUpdate: (conditionIndex: number, updater: (condition: ConditionBlock) => void) => void
|
| 346 |
+
}) {
|
| 347 |
+
return (
|
| 348 |
+
<div className="space-y-2">
|
| 349 |
+
<div className="font-mono text-[0.625rem] uppercase tracking-wider text-muted-foreground">{title}</div>
|
| 350 |
+
{conditions.map((condition, index) => (
|
| 351 |
+
<ConditionEditor
|
| 352 |
+
key={index}
|
| 353 |
+
condition={condition}
|
| 354 |
+
onUpdate={(updater) => onUpdate(index, updater)}
|
| 355 |
+
/>
|
| 356 |
+
))}
|
| 357 |
+
</div>
|
| 358 |
+
)
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
function ConditionEditor({
|
| 362 |
+
condition,
|
| 363 |
+
onUpdate,
|
| 364 |
+
}: {
|
| 365 |
+
condition: ConditionBlock
|
| 366 |
+
onUpdate: (updater: (condition: ConditionBlock) => void) => void
|
| 367 |
+
}) {
|
| 368 |
+
if (condition.present) {
|
| 369 |
+
return (
|
| 370 |
+
<EditorBlock label="present">
|
| 371 |
+
<ReadOnlyField label="label" value={condition.present.label} />
|
| 372 |
+
<NumberField label="min count" value={condition.present.min_count} min={1} step={1} onChange={(value) => onUpdate((draft) => { draft.present!.min_count = Math.max(1, Math.round(value)) })} />
|
| 373 |
+
</EditorBlock>
|
| 374 |
+
)
|
| 375 |
+
}
|
| 376 |
+
if (condition.count) {
|
| 377 |
+
return (
|
| 378 |
+
<EditorBlock label="count">
|
| 379 |
+
<ReadOnlyField label="label" value={condition.count.label} />
|
| 380 |
+
<NumberField label="minimum" value={condition.count.minimum} min={1} step={1} onChange={(value) => onUpdate((draft) => { draft.count!.minimum = Math.max(1, Math.round(value)) })} />
|
| 381 |
+
</EditorBlock>
|
| 382 |
+
)
|
| 383 |
+
}
|
| 384 |
+
if (condition.near) {
|
| 385 |
+
return (
|
| 386 |
+
<EditorBlock label="near">
|
| 387 |
+
<ReadOnlyField label="a" value={condition.near.a} />
|
| 388 |
+
<ReadOnlyField label="b" value={condition.near.b} />
|
| 389 |
+
<NumberField label="max gap %" value={condition.near.max_gap_percent} min={0} step={1} onChange={(value) => onUpdate((draft) => { draft.near!.max_gap_percent = value })} />
|
| 390 |
+
</EditorBlock>
|
| 391 |
+
)
|
| 392 |
+
}
|
| 393 |
+
if (condition.far) {
|
| 394 |
+
return (
|
| 395 |
+
<EditorBlock label="far">
|
| 396 |
+
<ReadOnlyField label="a" value={condition.far.a} />
|
| 397 |
+
<ReadOnlyField label="b" value={condition.far.b} />
|
| 398 |
+
<NumberField label="min gap %" value={condition.far.min_gap_percent} min={0} step={1} onChange={(value) => onUpdate((draft) => { draft.far!.min_gap_percent = value })} />
|
| 399 |
+
</EditorBlock>
|
| 400 |
+
)
|
| 401 |
+
}
|
| 402 |
+
if (condition.moving) {
|
| 403 |
+
return (
|
| 404 |
+
<EditorBlock label="moving">
|
| 405 |
+
<ReadOnlyField label="label" value={condition.moving.label} />
|
| 406 |
+
<NumberField label="min move (×size)" value={condition.moving.min_displacement_ratio} min={0} step={0.05} onChange={(value) => onUpdate((draft) => { draft.moving!.min_displacement_ratio = value })} />
|
| 407 |
+
<NumberField label="frames" value={condition.moving.window_frames} min={3} step={1} onChange={(value) => onUpdate((draft) => { draft.moving!.window_frames = Math.max(3, Math.round(value)) })} />
|
| 408 |
+
<NumberField label="misses" value={condition.moving.max_missing_frames} min={0} step={1} onChange={(value) => onUpdate((draft) => { draft.moving!.max_missing_frames = Math.max(0, Math.round(value)) })} />
|
| 409 |
+
</EditorBlock>
|
| 410 |
+
)
|
| 411 |
+
}
|
| 412 |
+
if (condition.cooldown) {
|
| 413 |
+
return (
|
| 414 |
+
<EditorBlock label="cooldown">
|
| 415 |
+
<CooldownEditor cooldown={condition.cooldown} onChange={(next) => onUpdate((draft) => { draft.cooldown = next })} />
|
| 416 |
+
</EditorBlock>
|
| 417 |
+
)
|
| 418 |
+
}
|
| 419 |
+
return null
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
function CooldownEditor({
|
| 423 |
+
cooldown,
|
| 424 |
+
onChange,
|
| 425 |
+
}: {
|
| 426 |
+
cooldown: CooldownCondition
|
| 427 |
+
onChange: (next: CooldownCondition) => void
|
| 428 |
+
}) {
|
| 429 |
+
return (
|
| 430 |
+
<div className="grid gap-2 sm:grid-cols-3">
|
| 431 |
+
<TextField label="key" value={cooldown.key ?? ""} onChange={(value) => onChange({ ...cooldown, key: value || null })} />
|
| 432 |
+
<OptionalNumberField label="seconds" value={cooldown.seconds} onChange={(value) => onChange({ ...cooldown, seconds: value, minutes: null })} />
|
| 433 |
+
<OptionalNumberField label="minutes" value={cooldown.minutes} onChange={(value) => onChange({ ...cooldown, minutes: value, seconds: null })} />
|
| 434 |
+
</div>
|
| 435 |
+
)
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
function ActionList({
|
| 439 |
+
rule,
|
| 440 |
+
onUpdate,
|
| 441 |
+
}: {
|
| 442 |
+
rule: AutomationRule
|
| 443 |
+
onUpdate: (bucket: ActionBucket, actionIndex: number, updater: (action: ActionSpec) => void) => void
|
| 444 |
+
}) {
|
| 445 |
+
const actions = editableActions(rule)
|
| 446 |
+
return (
|
| 447 |
+
<div className="space-y-2">
|
| 448 |
+
<div className="font-mono text-[0.625rem] uppercase tracking-wider text-muted-foreground">Actions</div>
|
| 449 |
+
{actions.map(({ action, bucket, actionIndex, label }) => (
|
| 450 |
+
<EditorBlock key={`${bucket}-${actionIndex}`} label={label}>
|
| 451 |
+
<TextField label="name" value={action.name} onChange={(value) => onUpdate(bucket, actionIndex, (draft) => { draft.name = value })} />
|
| 452 |
+
<div className="space-y-1">
|
| 453 |
+
<Label className="text-[0.6875rem] text-muted-foreground">type</Label>
|
| 454 |
+
<select
|
| 455 |
+
value={action.type}
|
| 456 |
+
onChange={(event) => onUpdate(bucket, actionIndex, (draft) => { draft.type = event.target.value as ActionSpec["type"] })}
|
| 457 |
+
className="h-9 w-full rounded-md border border-input bg-black/20 px-3 text-sm text-foreground focus-visible:outline-none focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-ring"
|
| 458 |
+
>
|
| 459 |
+
<option value="simulate">simulate</option>
|
| 460 |
+
<option value="webhook">webhook</option>
|
| 461 |
+
</select>
|
| 462 |
+
</div>
|
| 463 |
+
{action.type === "webhook" && (
|
| 464 |
+
<TextField label="url" value={action.url ?? ""} onChange={(value) => onUpdate(bucket, actionIndex, (draft) => { draft.url = value || null })} />
|
| 465 |
+
)}
|
| 466 |
+
</EditorBlock>
|
| 467 |
+
))}
|
| 468 |
+
</div>
|
| 469 |
+
)
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
function EditorBlock({ label, children }: { label: string; children: React.ReactNode }) {
|
| 473 |
+
return (
|
| 474 |
+
<div className="rounded-md border border-border bg-black/20 p-3">
|
| 475 |
+
<div className="mb-2 font-mono text-[0.625rem] uppercase tracking-wider text-primary">{label}</div>
|
| 476 |
+
<div className="grid gap-2 sm:grid-cols-2">{children}</div>
|
| 477 |
+
</div>
|
| 478 |
+
)
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
function TextField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
| 482 |
+
return (
|
| 483 |
+
<div className="space-y-1">
|
| 484 |
+
<Label className="text-[0.6875rem] text-muted-foreground">{label}</Label>
|
| 485 |
+
<Input value={value} onChange={(event) => onChange(event.target.value)} />
|
| 486 |
+
</div>
|
| 487 |
+
)
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
function ReadOnlyField({ label, value }: { label: string; value: string }) {
|
| 491 |
+
return (
|
| 492 |
+
<div className="space-y-1">
|
| 493 |
+
<Label className="text-[0.6875rem] text-muted-foreground">{label}</Label>
|
| 494 |
+
<div className="flex h-9 items-center rounded-md border border-border bg-black/30 px-3 font-mono text-xs text-muted-foreground">
|
| 495 |
+
{value}
|
| 496 |
+
</div>
|
| 497 |
+
</div>
|
| 498 |
+
)
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
function NumberField({
|
| 502 |
+
label,
|
| 503 |
+
value,
|
| 504 |
+
min,
|
| 505 |
+
step,
|
| 506 |
+
onChange,
|
| 507 |
+
}: {
|
| 508 |
+
label: string
|
| 509 |
+
value: number
|
| 510 |
+
min?: number
|
| 511 |
+
step?: number
|
| 512 |
+
onChange: (value: number) => void
|
| 513 |
+
}) {
|
| 514 |
+
return (
|
| 515 |
+
<div className="space-y-1">
|
| 516 |
+
<Label className="text-[0.6875rem] text-muted-foreground">{label}</Label>
|
| 517 |
+
<Input
|
| 518 |
+
type="number"
|
| 519 |
+
value={Number.isFinite(value) ? value : 0}
|
| 520 |
+
min={min}
|
| 521 |
+
step={step}
|
| 522 |
+
onChange={(event) => onChange(Number(event.target.value || 0))}
|
| 523 |
+
/>
|
| 524 |
+
</div>
|
| 525 |
+
)
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
function OptionalNumberField({
|
| 529 |
+
label,
|
| 530 |
+
value,
|
| 531 |
+
onChange,
|
| 532 |
+
}: {
|
| 533 |
+
label: string
|
| 534 |
+
value: number | null
|
| 535 |
+
onChange: (value: number | null) => void
|
| 536 |
+
}) {
|
| 537 |
+
return (
|
| 538 |
+
<div className="space-y-1">
|
| 539 |
+
<Label className="text-[0.6875rem] text-muted-foreground">{label}</Label>
|
| 540 |
+
<Input
|
| 541 |
+
type="number"
|
| 542 |
+
value={value ?? ""}
|
| 543 |
+
min={0}
|
| 544 |
+
step={1}
|
| 545 |
+
onChange={(event) => onChange(event.target.value === "" ? null : Number(event.target.value))}
|
| 546 |
+
/>
|
| 547 |
+
</div>
|
| 548 |
+
)
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
type ActionBucket = "then" | "enter" | "exit" | "while"
|
| 552 |
+
|
| 553 |
+
function editableActions(rule: AutomationRule): Array<{
|
| 554 |
+
bucket: ActionBucket
|
| 555 |
+
actionIndex: number
|
| 556 |
+
label: string
|
| 557 |
+
action: ActionSpec
|
| 558 |
+
}> {
|
| 559 |
+
if (Array.isArray(rule.then)) {
|
| 560 |
+
return rule.then.map((action, actionIndex) => ({ bucket: "then", actionIndex, label: "then", action }))
|
| 561 |
+
}
|
| 562 |
+
return [
|
| 563 |
+
...rule.then.enter.map((action, actionIndex) => ({ bucket: "enter" as const, actionIndex, label: "enter", action })),
|
| 564 |
+
...rule.then.exit.map((action, actionIndex) => ({ bucket: "exit" as const, actionIndex, label: "exit", action })),
|
| 565 |
+
...rule.then.while.map((action, actionIndex) => ({ bucket: "while" as const, actionIndex, label: "while", action })),
|
| 566 |
+
]
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
function ruleActions(rule: AutomationRule): ActionSpec[] {
|
| 570 |
+
return editableActions(rule).map((item) => item.action)
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
function updateRuleAction(
|
| 574 |
+
rule: AutomationRule,
|
| 575 |
+
bucket: ActionBucket,
|
| 576 |
+
actionIndex: number,
|
| 577 |
+
updater: (action: ActionSpec) => void,
|
| 578 |
+
) {
|
| 579 |
+
if (Array.isArray(rule.then)) {
|
| 580 |
+
const action = rule.then[actionIndex]
|
| 581 |
+
if (action) updater(action)
|
| 582 |
+
return
|
| 583 |
+
}
|
| 584 |
+
const action = rule.then[bucket === "then" ? "enter" : bucket][actionIndex]
|
| 585 |
+
if (action) updater(action)
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
function cloneDocument(document: AutomationDocument): AutomationDocument {
|
| 589 |
+
return JSON.parse(JSON.stringify(document)) as AutomationDocument
|
| 590 |
+
}
|
|
@@ -13,6 +13,7 @@ does not require torch/ultralytics.
|
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
import os
|
|
|
|
| 16 |
from pathlib import Path
|
| 17 |
from typing import Any
|
| 18 |
|
|
@@ -50,6 +51,11 @@ RENDERS = ROOT / ".local" / "renders"
|
|
| 50 |
RENDERS.mkdir(parents=True, exist_ok=True)
|
| 51 |
|
| 52 |
DEFAULT_MODEL = "yoloe-26s-seg.pt"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
# ── serialization helpers (JSON-shaped, for the custom frontend) ─────────────
|
|
@@ -120,6 +126,18 @@ def detect_and_automate(
|
|
| 120 |
|
| 121 |
rules = load_automation_text(rules_text)
|
| 122 |
detection_classes = _merge_class_names(parse_class_prompt(classes), document_labels(rules))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
result = process_video(
|
| 125 |
video_path=video_path,
|
|
@@ -132,8 +150,15 @@ def detect_and_automate(
|
|
| 132 |
image_size=image_size or None,
|
| 133 |
device=None if device in ("", "auto") else device,
|
| 134 |
max_detections=max_detections or None,
|
|
|
|
| 135 |
output_dir=str(RENDERS),
|
| 136 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
events, _last_fired = evaluate_video_detections(
|
| 139 |
rules.rules,
|
|
@@ -147,6 +172,7 @@ def detect_and_automate(
|
|
| 147 |
events, enable_webhooks=enable_webhooks, webhook_url=webhook_url or None
|
| 148 |
)
|
| 149 |
append_events(dispatched)
|
|
|
|
| 150 |
|
| 151 |
automation_path = render_automation_video(
|
| 152 |
source_video_path=video_path,
|
|
@@ -156,6 +182,7 @@ def detect_and_automate(
|
|
| 156 |
max_frames=max_frames,
|
| 157 |
output_dir=str(RENDERS),
|
| 158 |
)
|
|
|
|
| 159 |
|
| 160 |
fired = len(dispatched)
|
| 161 |
return {
|
|
@@ -249,10 +276,11 @@ def validate_rules(rules_text: str) -> dict:
|
|
| 249 |
try:
|
| 250 |
document = load_automation_text(rules_text)
|
| 251 |
except Exception as exc: # noqa: BLE001 — surface any validation error to the UI
|
| 252 |
-
return {"ok": False, "error": str(exc), "rules": []}
|
| 253 |
return {
|
| 254 |
"ok": True,
|
| 255 |
"error": None,
|
|
|
|
| 256 |
"rules": [
|
| 257 |
{
|
| 258 |
"name": r.name,
|
|
@@ -355,6 +383,14 @@ def _merge_class_names(*groups: list[str]) -> list[str]:
|
|
| 355 |
return merged
|
| 356 |
|
| 357 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
# ── static frontend + media (custom routes take priority over gradio's) ──────
|
| 359 |
@app.get("/", response_class=HTMLResponse)
|
| 360 |
def index() -> Any:
|
|
|
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
import os
|
| 16 |
+
import logging
|
| 17 |
from pathlib import Path
|
| 18 |
from typing import Any
|
| 19 |
|
|
|
|
| 51 |
RENDERS.mkdir(parents=True, exist_ok=True)
|
| 52 |
|
| 53 |
DEFAULT_MODEL = "yoloe-26s-seg.pt"
|
| 54 |
+
logging.basicConfig(
|
| 55 |
+
level=os.environ.get("TINY_TRIGGER_LOG_LEVEL", "INFO").upper(),
|
| 56 |
+
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
| 57 |
+
)
|
| 58 |
+
LOGGER = logging.getLogger(__name__)
|
| 59 |
|
| 60 |
|
| 61 |
# ── serialization helpers (JSON-shaped, for the custom frontend) ─────────────
|
|
|
|
| 126 |
|
| 127 |
rules = load_automation_text(rules_text)
|
| 128 |
detection_classes = _merge_class_names(parse_class_prompt(classes), document_labels(rules))
|
| 129 |
+
tracking_enabled = _document_uses_moving(rules)
|
| 130 |
+
LOGGER.info(
|
| 131 |
+
"Starting detect_and_automate video=%s classes=%s rules=%s sample_interval=%s max_frames=%s model=%s image_size=%s tracking=%s",
|
| 132 |
+
Path(video_path).name,
|
| 133 |
+
", ".join(detection_classes),
|
| 134 |
+
len(rules.rules),
|
| 135 |
+
sample_interval_sec,
|
| 136 |
+
max_frames,
|
| 137 |
+
model_name or DEFAULT_MODEL,
|
| 138 |
+
image_size or "default",
|
| 139 |
+
tracking_enabled,
|
| 140 |
+
)
|
| 141 |
|
| 142 |
result = process_video(
|
| 143 |
video_path=video_path,
|
|
|
|
| 150 |
image_size=image_size or None,
|
| 151 |
device=None if device in ("", "auto") else device,
|
| 152 |
max_detections=max_detections or None,
|
| 153 |
+
tracking_enabled=tracking_enabled,
|
| 154 |
output_dir=str(RENDERS),
|
| 155 |
)
|
| 156 |
+
LOGGER.info(
|
| 157 |
+
"Detection complete: sampled_frames=%s detections=%s annotated=%s",
|
| 158 |
+
result.processed_frames,
|
| 159 |
+
len(result.detections),
|
| 160 |
+
result.output_video_path,
|
| 161 |
+
)
|
| 162 |
|
| 163 |
events, _last_fired = evaluate_video_detections(
|
| 164 |
rules.rules,
|
|
|
|
| 172 |
events, enable_webhooks=enable_webhooks, webhook_url=webhook_url or None
|
| 173 |
)
|
| 174 |
append_events(dispatched)
|
| 175 |
+
LOGGER.info("Automation evaluation complete: events=%s dispatched=%s", len(events), len(dispatched))
|
| 176 |
|
| 177 |
automation_path = render_automation_video(
|
| 178 |
source_video_path=video_path,
|
|
|
|
| 182 |
max_frames=max_frames,
|
| 183 |
output_dir=str(RENDERS),
|
| 184 |
)
|
| 185 |
+
LOGGER.info("detect_and_automate complete: output=%s", automation_path)
|
| 186 |
|
| 187 |
fired = len(dispatched)
|
| 188 |
return {
|
|
|
|
| 276 |
try:
|
| 277 |
document = load_automation_text(rules_text)
|
| 278 |
except Exception as exc: # noqa: BLE001 — surface any validation error to the UI
|
| 279 |
+
return {"ok": False, "error": str(exc), "rules": [], "document": None}
|
| 280 |
return {
|
| 281 |
"ok": True,
|
| 282 |
"error": None,
|
| 283 |
+
"document": document.model_dump(mode="json", by_alias=True),
|
| 284 |
"rules": [
|
| 285 |
{
|
| 286 |
"name": r.name,
|
|
|
|
| 383 |
return merged
|
| 384 |
|
| 385 |
|
| 386 |
+
def _document_uses_moving(document: AutomationDocument) -> bool:
|
| 387 |
+
for rule in document.rules:
|
| 388 |
+
for condition in [*rule.when.all_conditions, *rule.when.any_conditions]:
|
| 389 |
+
if condition.moving is not None:
|
| 390 |
+
return True
|
| 391 |
+
return False
|
| 392 |
+
|
| 393 |
+
|
| 394 |
# ── static frontend + media (custom routes take priority over gradio's) ──────
|
| 395 |
@app.get("/", response_class=HTMLResponse)
|
| 396 |
def index() -> Any:
|
|
@@ -138,7 +138,7 @@ def test_moving_condition_uses_tracked_centroid_history() -> None:
|
|
| 138 |
"name": "car-moving",
|
| 139 |
"when": {
|
| 140 |
"all": [
|
| 141 |
-
{"moving": {"label": "car", "
|
| 142 |
]
|
| 143 |
},
|
| 144 |
"then": [{"type": "simulate", "name": "notify"}],
|
|
@@ -170,6 +170,46 @@ def test_moving_condition_uses_tracked_centroid_history() -> None:
|
|
| 170 |
assert [event.action for event in third] == ["notify"]
|
| 171 |
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
def test_moving_condition_migrates_short_window_to_three() -> None:
|
| 174 |
document = load_automation_text(
|
| 175 |
json.dumps(
|
|
@@ -202,7 +242,7 @@ def test_moving_condition_does_not_reenter_after_one_missed_frame() -> None:
|
|
| 202 |
{
|
| 203 |
"moving": {
|
| 204 |
"label": "car",
|
| 205 |
-
"
|
| 206 |
"window_frames": 3,
|
| 207 |
"max_missing_frames": 1,
|
| 208 |
}
|
|
|
|
| 138 |
"name": "car-moving",
|
| 139 |
"when": {
|
| 140 |
"all": [
|
| 141 |
+
{"moving": {"label": "car", "min_displacement_ratio": 0.15, "window_frames": 3}}
|
| 142 |
]
|
| 143 |
},
|
| 144 |
"then": [{"type": "simulate", "name": "notify"}],
|
|
|
|
| 170 |
assert [event.action for event in third] == ["notify"]
|
| 171 |
|
| 172 |
|
| 173 |
+
def test_moving_condition_ignores_box_jitter_on_stationary_object() -> None:
|
| 174 |
+
document = load_automation_text(
|
| 175 |
+
json.dumps(
|
| 176 |
+
{
|
| 177 |
+
"rules": [
|
| 178 |
+
{
|
| 179 |
+
"name": "person-moving",
|
| 180 |
+
"when": {
|
| 181 |
+
"all": [
|
| 182 |
+
{"moving": {"label": "person", "min_displacement_ratio": 0.15, "window_frames": 3}}
|
| 183 |
+
]
|
| 184 |
+
},
|
| 185 |
+
"then": [{"type": "simulate", "name": "notify"}],
|
| 186 |
+
}
|
| 187 |
+
]
|
| 188 |
+
}
|
| 189 |
+
)
|
| 190 |
+
)
|
| 191 |
+
engine = RuleEngine(document.rules)
|
| 192 |
+
|
| 193 |
+
# Stationary person; only the box's top edge flickers (arms/occlusion/shadow),
|
| 194 |
+
# which swings the geometric center but leaves the foot point essentially put.
|
| 195 |
+
# The old center-displacement check fired on this; the foot-point + size-ratio
|
| 196 |
+
# check must not.
|
| 197 |
+
frames = [
|
| 198 |
+
(0.45, 0.50, 0.55, 0.80),
|
| 199 |
+
(0.46, 0.42, 0.56, 0.80),
|
| 200 |
+
(0.44, 0.34, 0.54, 0.80),
|
| 201 |
+
]
|
| 202 |
+
events = []
|
| 203 |
+
for index, box in enumerate(frames):
|
| 204 |
+
events += engine.evaluate_frame(
|
| 205 |
+
[detection("person", box, frame=index, time=float(index), track_id=1)],
|
| 206 |
+
frame_index=index,
|
| 207 |
+
timestamp_sec=float(index),
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
assert events == []
|
| 211 |
+
|
| 212 |
+
|
| 213 |
def test_moving_condition_migrates_short_window_to_three() -> None:
|
| 214 |
document = load_automation_text(
|
| 215 |
json.dumps(
|
|
|
|
| 242 |
{
|
| 243 |
"moving": {
|
| 244 |
"label": "car",
|
| 245 |
+
"min_displacement_ratio": 0.15,
|
| 246 |
"window_frames": 3,
|
| 247 |
"max_missing_frames": 1,
|
| 248 |
}
|
|
@@ -1,9 +1,44 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from tiny_trigger.detector import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from tiny_trigger.models import Detection
|
| 5 |
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
def test_parse_class_prompt_splits_and_dedupes() -> None:
|
| 8 |
assert parse_class_prompt(" cat, feeder robot\npackage; cat ") == ["cat", "feeder robot", "package"]
|
| 9 |
|
|
@@ -34,3 +69,136 @@ def test_suppress_duplicate_detections_keeps_highest_confidence_same_label_box()
|
|
| 34 |
assert ("person", 0.62) not in [(item.label, item.confidence) for item in filtered]
|
| 35 |
assert ("person", 0.80) in [(item.label, item.confidence) for item in filtered]
|
| 36 |
assert ("bag", 0.70) in [(item.label, item.confidence) for item in filtered]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from tiny_trigger.detector import (
|
| 4 |
+
GreedyIoUTracker,
|
| 5 |
+
UltralyticsYOLOEDetector,
|
| 6 |
+
detections_from_ultralytics_result,
|
| 7 |
+
parse_class_prompt,
|
| 8 |
+
suppress_duplicate_detections,
|
| 9 |
+
)
|
| 10 |
from tiny_trigger.models import Detection
|
| 11 |
|
| 12 |
|
| 13 |
+
class TensorLike:
|
| 14 |
+
def __init__(self, values):
|
| 15 |
+
self.values = values
|
| 16 |
+
|
| 17 |
+
def cpu(self):
|
| 18 |
+
return self
|
| 19 |
+
|
| 20 |
+
def tolist(self):
|
| 21 |
+
return self.values
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class BoxesLike:
|
| 25 |
+
def __init__(self, *, ids=None):
|
| 26 |
+
self.xyxy = TensorLike([[10.0, 20.0, 30.0, 40.0], [40.0, 50.0, 70.0, 90.0]])
|
| 27 |
+
self.conf = TensorLike([0.91, 0.82])
|
| 28 |
+
self.cls = TensorLike([0, 1])
|
| 29 |
+
self.id = TensorLike(ids) if ids is not None else None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ResultLike:
|
| 33 |
+
def __init__(self, *, ids=None):
|
| 34 |
+
self.boxes = BoxesLike(ids=ids)
|
| 35 |
+
self.names = {0: "person", 1: "car"}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class FrameLike:
|
| 39 |
+
shape = (100, 100, 3)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
def test_parse_class_prompt_splits_and_dedupes() -> None:
|
| 43 |
assert parse_class_prompt(" cat, feeder robot\npackage; cat ") == ["cat", "feeder robot", "package"]
|
| 44 |
|
|
|
|
| 69 |
assert ("person", 0.62) not in [(item.label, item.confidence) for item in filtered]
|
| 70 |
assert ("person", 0.80) in [(item.label, item.confidence) for item in filtered]
|
| 71 |
assert ("bag", 0.70) in [(item.label, item.confidence) for item in filtered]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_suppress_duplicate_detections_keeps_oldest_track_id() -> None:
|
| 75 |
+
def tracked(confidence: float, box: tuple[float, float, float, float], track_id: int) -> Detection:
|
| 76 |
+
return Detection(
|
| 77 |
+
frame_index=0,
|
| 78 |
+
timestamp_sec=0.0,
|
| 79 |
+
label="person",
|
| 80 |
+
confidence=confidence,
|
| 81 |
+
bbox_xyxy=(0.0, 0.0, 10.0, 10.0),
|
| 82 |
+
bbox_xyxy_norm=box,
|
| 83 |
+
track_id=track_id,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# Higher-confidence box is the newer track (id 7); the suppressed
|
| 87 |
+
# lower-confidence box is the established track (id 5).
|
| 88 |
+
detections = [
|
| 89 |
+
tracked(0.91, (0.11, 0.11, 0.51, 0.51), 7),
|
| 90 |
+
tracked(0.62, (0.10, 0.10, 0.50, 0.50), 5),
|
| 91 |
+
]
|
| 92 |
+
|
| 93 |
+
filtered = suppress_duplicate_detections(detections, iou_threshold=0.8)
|
| 94 |
+
|
| 95 |
+
assert len(filtered) == 1
|
| 96 |
+
assert filtered[0].confidence == 0.91 # highest-confidence geometry kept
|
| 97 |
+
assert filtered[0].track_id == 5 # identity follows the oldest track, not confidence
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_greedy_tracker_matches_overlap_and_keeps_unmatched() -> None:
|
| 101 |
+
tracker = GreedyIoUTracker(iou_threshold=0.3)
|
| 102 |
+
|
| 103 |
+
def det(label: str, box: tuple[float, float, float, float]) -> Detection:
|
| 104 |
+
return Detection(
|
| 105 |
+
frame_index=0,
|
| 106 |
+
timestamp_sec=0.0,
|
| 107 |
+
label=label,
|
| 108 |
+
confidence=0.9,
|
| 109 |
+
bbox_xyxy=(0.0, 0.0, 10.0, 10.0),
|
| 110 |
+
bbox_xyxy_norm=box,
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
first = tracker.assign([det("person", (0.10, 0.10, 0.20, 0.40))])
|
| 114 |
+
person_id = first[0].track_id
|
| 115 |
+
assert person_id is not None
|
| 116 |
+
|
| 117 |
+
# Next frame: the same person (overlapping box) keeps its id; a second person
|
| 118 |
+
# that matches no existing track is still returned with a fresh id, never dropped.
|
| 119 |
+
second = tracker.assign(
|
| 120 |
+
[
|
| 121 |
+
det("person", (0.11, 0.10, 0.21, 0.40)),
|
| 122 |
+
det("person", (0.70, 0.10, 0.80, 0.40)),
|
| 123 |
+
]
|
| 124 |
+
)
|
| 125 |
+
ids = [item.track_id for item in second]
|
| 126 |
+
assert len(second) == 2 # nothing dropped for failing to match
|
| 127 |
+
assert person_id in ids
|
| 128 |
+
assert len(set(ids)) == 2 # the unmatched one got its own id
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_detections_from_ultralytics_result_reads_track_ids() -> None:
|
| 132 |
+
detections = detections_from_ultralytics_result(
|
| 133 |
+
ResultLike(ids=[7, 8]),
|
| 134 |
+
frame_shape=(100, 100, 3),
|
| 135 |
+
frame_index=3,
|
| 136 |
+
timestamp_sec=1.5,
|
| 137 |
+
fallback_names=["person", "car"],
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
assert [(item.label, item.track_id) for item in detections] == [("car", 8), ("person", 7)]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def test_detections_from_ultralytics_result_allows_missing_track_ids() -> None:
|
| 144 |
+
detections = detections_from_ultralytics_result(
|
| 145 |
+
ResultLike(),
|
| 146 |
+
frame_shape=(100, 100, 3),
|
| 147 |
+
frame_index=3,
|
| 148 |
+
timestamp_sec=1.5,
|
| 149 |
+
fallback_names=["person", "car"],
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
assert [item.track_id for item in detections] == [None, None]
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def test_ultralytics_detector_uses_predict_by_default() -> None:
|
| 156 |
+
calls = []
|
| 157 |
+
|
| 158 |
+
class ModelLike:
|
| 159 |
+
def predict(self, frame, **kwargs):
|
| 160 |
+
calls.append(("predict", frame, kwargs))
|
| 161 |
+
return [ResultLike()]
|
| 162 |
+
|
| 163 |
+
detector = UltralyticsYOLOEDetector.__new__(UltralyticsYOLOEDetector)
|
| 164 |
+
detector.class_names = ["person", "car"]
|
| 165 |
+
detector.model_name = "fake.pt"
|
| 166 |
+
detector.device = None
|
| 167 |
+
detector.tracking_enabled = False
|
| 168 |
+
detector._tracker = None
|
| 169 |
+
detector.model = ModelLike()
|
| 170 |
+
frame = FrameLike()
|
| 171 |
+
|
| 172 |
+
detections = detector.detect(frame, frame_index=1, timestamp_sec=0.5, confidence=0.25)
|
| 173 |
+
|
| 174 |
+
assert [item.track_id for item in detections] == [None, None]
|
| 175 |
+
assert calls == [("predict", frame, {"conf": 0.25, "verbose": False})]
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def test_ultralytics_detector_predicts_and_assigns_track_ids_when_tracking_enabled() -> None:
|
| 179 |
+
calls = []
|
| 180 |
+
|
| 181 |
+
class ModelLike:
|
| 182 |
+
def predict(self, frame, **kwargs):
|
| 183 |
+
calls.append("predict")
|
| 184 |
+
return [ResultLike()]
|
| 185 |
+
|
| 186 |
+
detector = UltralyticsYOLOEDetector.__new__(UltralyticsYOLOEDetector)
|
| 187 |
+
detector.class_names = ["person", "car"]
|
| 188 |
+
detector.model_name = "fake.pt"
|
| 189 |
+
detector.device = None
|
| 190 |
+
detector.tracking_enabled = True
|
| 191 |
+
detector._tracker = GreedyIoUTracker()
|
| 192 |
+
detector.model = ModelLike()
|
| 193 |
+
frame = FrameLike()
|
| 194 |
+
|
| 195 |
+
first = detector.detect(frame, frame_index=1, timestamp_sec=0.5, confidence=0.25)
|
| 196 |
+
second = detector.detect(frame, frame_index=2, timestamp_sec=1.0, confidence=0.25)
|
| 197 |
+
|
| 198 |
+
# Detection always runs through predict (never track); IDs come from our tracker.
|
| 199 |
+
assert calls == ["predict", "predict"]
|
| 200 |
+
first_ids = {item.label: item.track_id for item in first}
|
| 201 |
+
assert all(track_id is not None for track_id in first_ids.values())
|
| 202 |
+
# Same boxes the next frame -> same IDs (stable identity, nothing dropped).
|
| 203 |
+
second_ids = {item.label: item.track_id for item in second}
|
| 204 |
+
assert second_ids == first_ids
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import json
|
| 4 |
|
| 5 |
-
from server import _merge_class_names, _merge_documents
|
| 6 |
from tiny_trigger.automation import AutomationDocument, load_automation_text
|
| 7 |
|
| 8 |
|
|
@@ -47,3 +47,35 @@ def test_merge_class_names_adds_rule_labels_without_duplicates() -> None:
|
|
| 47 |
"Monitor",
|
| 48 |
"guitar",
|
| 49 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import json
|
| 4 |
|
| 5 |
+
from server import _document_uses_moving, _merge_class_names, _merge_documents
|
| 6 |
from tiny_trigger.automation import AutomationDocument, load_automation_text
|
| 7 |
|
| 8 |
|
|
|
|
| 47 |
"Monitor",
|
| 48 |
"guitar",
|
| 49 |
]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_document_uses_moving_only_for_motion_rules() -> None:
|
| 53 |
+
presence = load_automation_text(
|
| 54 |
+
json.dumps(
|
| 55 |
+
{
|
| 56 |
+
"rules": [
|
| 57 |
+
{
|
| 58 |
+
"name": "person-visible",
|
| 59 |
+
"when": {"all": [{"present": {"label": "person"}}]},
|
| 60 |
+
"then": [{"type": "simulate", "name": "notify"}],
|
| 61 |
+
}
|
| 62 |
+
]
|
| 63 |
+
}
|
| 64 |
+
)
|
| 65 |
+
)
|
| 66 |
+
moving = load_automation_text(
|
| 67 |
+
json.dumps(
|
| 68 |
+
{
|
| 69 |
+
"rules": [
|
| 70 |
+
{
|
| 71 |
+
"name": "car-moving",
|
| 72 |
+
"when": {"all": [{"moving": {"label": "car"}}]},
|
| 73 |
+
"then": [{"type": "simulate", "name": "notify"}],
|
| 74 |
+
}
|
| 75 |
+
]
|
| 76 |
+
}
|
| 77 |
+
)
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
assert not _document_uses_moving(presence)
|
| 81 |
+
assert _document_uses_moving(moving)
|
|
@@ -1,26 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from tiny_trigger.models import Detection
|
| 4 |
-
from tiny_trigger.tracking import SimpleTracker
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
def detection(label: str, box: tuple[float, float, float, float]) -> Detection:
|
| 8 |
-
return Detection(
|
| 9 |
-
frame_index=0,
|
| 10 |
-
timestamp_sec=0.0,
|
| 11 |
-
label=label,
|
| 12 |
-
confidence=0.9,
|
| 13 |
-
bbox_xyxy=(0.0, 0.0, 10.0, 10.0),
|
| 14 |
-
bbox_xyxy_norm=box,
|
| 15 |
-
)
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def test_simple_tracker_keeps_same_label_track_id() -> None:
|
| 19 |
-
tracker = SimpleTracker(max_match_distance_percent=20)
|
| 20 |
-
|
| 21 |
-
first = tracker.update([detection("car", (0.10, 0.10, 0.20, 0.20))])
|
| 22 |
-
second = tracker.update([detection("car", (0.15, 0.10, 0.25, 0.20))])
|
| 23 |
-
third = tracker.update([detection("person", (0.15, 0.10, 0.25, 0.20))])
|
| 24 |
-
|
| 25 |
-
assert first[0].track_id == second[0].track_id
|
| 26 |
-
assert third[0].track_id != first[0].track_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -34,7 +34,7 @@ class FakeDetector:
|
|
| 34 |
]
|
| 35 |
|
| 36 |
|
| 37 |
-
class
|
| 38 |
class_names = ["cat"]
|
| 39 |
|
| 40 |
def detect(
|
|
@@ -56,6 +56,7 @@ class MovingDetector:
|
|
| 56 |
confidence=0.99,
|
| 57 |
bbox_xyxy=(2.0 + frame_index, 2.0, 12.0 + frame_index, 12.0),
|
| 58 |
bbox_xyxy_norm=(0.1 + offset, 0.1, 0.2 + offset, 0.2),
|
|
|
|
| 59 |
)
|
| 60 |
]
|
| 61 |
|
|
@@ -121,7 +122,7 @@ def test_process_video_with_fake_detector(tmp_path: Path) -> None:
|
|
| 121 |
capture.release()
|
| 122 |
|
| 123 |
|
| 124 |
-
def
|
| 125 |
video_path = _make_video(tmp_path, fps=10.0, frames=4)
|
| 126 |
|
| 127 |
result = process_video(
|
|
@@ -129,11 +130,28 @@ def test_process_video_assigns_track_ids(tmp_path: Path) -> None:
|
|
| 129 |
class_prompt="cat",
|
| 130 |
frame_stride=1,
|
| 131 |
max_frames=3,
|
| 132 |
-
detector=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
output_dir=str(tmp_path),
|
| 134 |
)
|
| 135 |
|
| 136 |
-
assert [item.track_id for item in result.detections] == [
|
| 137 |
|
| 138 |
|
| 139 |
def test_process_video_suppresses_duplicate_same_label_boxes(tmp_path: Path) -> None:
|
|
|
|
| 34 |
]
|
| 35 |
|
| 36 |
|
| 37 |
+
class TrackedDetector:
|
| 38 |
class_names = ["cat"]
|
| 39 |
|
| 40 |
def detect(
|
|
|
|
| 56 |
confidence=0.99,
|
| 57 |
bbox_xyxy=(2.0 + frame_index, 2.0, 12.0 + frame_index, 12.0),
|
| 58 |
bbox_xyxy_norm=(0.1 + offset, 0.1, 0.2 + offset, 0.2),
|
| 59 |
+
track_id=7,
|
| 60 |
)
|
| 61 |
]
|
| 62 |
|
|
|
|
| 122 |
capture.release()
|
| 123 |
|
| 124 |
|
| 125 |
+
def test_process_video_does_not_synthesize_track_ids(tmp_path: Path) -> None:
|
| 126 |
video_path = _make_video(tmp_path, fps=10.0, frames=4)
|
| 127 |
|
| 128 |
result = process_video(
|
|
|
|
| 130 |
class_prompt="cat",
|
| 131 |
frame_stride=1,
|
| 132 |
max_frames=3,
|
| 133 |
+
detector=FakeDetector(),
|
| 134 |
+
image_size=960,
|
| 135 |
+
max_detections=20,
|
| 136 |
+
output_dir=str(tmp_path),
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
assert [item.track_id for item in result.detections] == [None, None, None]
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_process_video_preserves_detector_track_ids(tmp_path: Path) -> None:
|
| 143 |
+
video_path = _make_video(tmp_path, fps=10.0, frames=4)
|
| 144 |
+
|
| 145 |
+
result = process_video(
|
| 146 |
+
video_path=str(video_path),
|
| 147 |
+
class_prompt="cat",
|
| 148 |
+
frame_stride=1,
|
| 149 |
+
max_frames=3,
|
| 150 |
+
detector=TrackedDetector(),
|
| 151 |
output_dir=str(tmp_path),
|
| 152 |
)
|
| 153 |
|
| 154 |
+
assert [item.track_id for item in result.detections] == [7, 7, 7]
|
| 155 |
|
| 156 |
|
| 157 |
def test_process_video_suppresses_duplicate_same_label_boxes(tmp_path: Path) -> None:
|
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Open-vocabulary video automations for Tiny Trigger."""
|
| 2 |
|
| 3 |
from .automation import RuleEngine, evaluate_video_detections, load_automation_text
|
| 4 |
-
from .detector import UltralyticsYOLOEDetector, parse_class_prompt
|
| 5 |
from .llm import (
|
| 6 |
DEFAULT_ANTHROPIC_MODEL,
|
| 7 |
DEFAULT_OPENAI_MODEL,
|
|
@@ -13,6 +13,7 @@ from .llm import (
|
|
| 13 |
from .video import process_video
|
| 14 |
|
| 15 |
__all__ = [
|
|
|
|
| 16 |
"RuleEngine",
|
| 17 |
"UltralyticsYOLOEDetector",
|
| 18 |
"DEFAULT_ANTHROPIC_MODEL",
|
|
|
|
| 1 |
"""Open-vocabulary video automations for Tiny Trigger."""
|
| 2 |
|
| 3 |
from .automation import RuleEngine, evaluate_video_detections, load_automation_text
|
| 4 |
+
from .detector import GreedyIoUTracker, UltralyticsYOLOEDetector, parse_class_prompt
|
| 5 |
from .llm import (
|
| 6 |
DEFAULT_ANTHROPIC_MODEL,
|
| 7 |
DEFAULT_OPENAI_MODEL,
|
|
|
|
| 13 |
from .video import process_video
|
| 14 |
|
| 15 |
__all__ = [
|
| 16 |
+
"GreedyIoUTracker",
|
| 17 |
"RuleEngine",
|
| 18 |
"UltralyticsYOLOEDetector",
|
| 19 |
"DEFAULT_ANTHROPIC_MODEL",
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
| 3 |
import json
|
| 4 |
from collections import defaultdict
|
| 5 |
from math import hypot
|
|
|
|
| 6 |
from typing import Any, Literal
|
| 7 |
|
| 8 |
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
@@ -63,7 +64,7 @@ class FarCondition(BaseModel):
|
|
| 63 |
|
| 64 |
class MovingCondition(BaseModel):
|
| 65 |
label: str
|
| 66 |
-
|
| 67 |
window_frames: int = Field(default=3, ge=3)
|
| 68 |
max_missing_frames: int = Field(default=1, ge=0)
|
| 69 |
|
|
@@ -240,7 +241,7 @@ class RuleEngine:
|
|
| 240 |
self.rules = rules
|
| 241 |
self.last_fired: dict[str, float] = dict(last_fired or {})
|
| 242 |
self.last_matched: dict[str, bool] = dict(last_matched or {})
|
| 243 |
-
self.track_history: dict[int, list[tuple[int, tuple[float, float]]]] = {}
|
| 244 |
self.moving_track_last_seen: dict[tuple[str, int], int] = {}
|
| 245 |
|
| 246 |
def evaluate_frame(
|
|
@@ -366,13 +367,19 @@ class RuleEngine:
|
|
| 366 |
history = self.track_history.get(detection.track_id, [])
|
| 367 |
if len(history) < condition.window_frames:
|
| 368 |
continue
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
displacement
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
self.moving_track_last_seen[(label, detection.track_id)] = detection.frame_index
|
| 377 |
return True
|
| 378 |
if label_detections:
|
|
@@ -389,7 +396,9 @@ class RuleEngine:
|
|
| 389 |
if detection.track_id is None:
|
| 390 |
continue
|
| 391 |
history = self.track_history.setdefault(detection.track_id, [])
|
| 392 |
-
history.append(
|
|
|
|
|
|
|
| 393 |
del history[:-10]
|
| 394 |
|
| 395 |
def _mark_cooldowns(self, rule: AutomationRule, timestamp_sec: float) -> None:
|
|
@@ -456,9 +465,21 @@ def _box_gap_percent(left: Detection, right: Detection) -> float:
|
|
| 456 |
return max(gap_x, gap_y) * 100.0
|
| 457 |
|
| 458 |
|
| 459 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
x1, y1, x2, y2 = detection.bbox_xyxy_norm
|
| 461 |
-
return ((
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
|
| 463 |
|
| 464 |
def _trigger_edge(*, previous: bool, matched: bool) -> Literal["enter", "exit", "while", "none"]:
|
|
|
|
| 3 |
import json
|
| 4 |
from collections import defaultdict
|
| 5 |
from math import hypot
|
| 6 |
+
from statistics import median
|
| 7 |
from typing import Any, Literal
|
| 8 |
|
| 9 |
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
|
|
| 64 |
|
| 65 |
class MovingCondition(BaseModel):
|
| 66 |
label: str
|
| 67 |
+
min_displacement_ratio: float = Field(default=0.15, ge=0.0)
|
| 68 |
window_frames: int = Field(default=3, ge=3)
|
| 69 |
max_missing_frames: int = Field(default=1, ge=0)
|
| 70 |
|
|
|
|
| 241 |
self.rules = rules
|
| 242 |
self.last_fired: dict[str, float] = dict(last_fired or {})
|
| 243 |
self.last_matched: dict[str, bool] = dict(last_matched or {})
|
| 244 |
+
self.track_history: dict[int, list[tuple[int, tuple[float, float], float]]] = {}
|
| 245 |
self.moving_track_last_seen: dict[tuple[str, int], int] = {}
|
| 246 |
|
| 247 |
def evaluate_frame(
|
|
|
|
| 367 |
history = self.track_history.get(detection.track_id, [])
|
| 368 |
if len(history) < condition.window_frames:
|
| 369 |
continue
|
| 370 |
+
window = history[-condition.window_frames :]
|
| 371 |
+
# Smooth out box jitter by averaging each half of the window, then
|
| 372 |
+
# measure displacement relative to the object's own size (its median
|
| 373 |
+
# box diagonal) so the threshold is distance-invariant and a flickering
|
| 374 |
+
# box on a stationary object stays well under it.
|
| 375 |
+
half = condition.window_frames // 2
|
| 376 |
+
early = _mean_point([point for _frame, point, _size in window[:half]])
|
| 377 |
+
late = _mean_point([point for _frame, point, _size in window[-half:]])
|
| 378 |
+
size = median(size for _frame, _point, size in window)
|
| 379 |
+
if size <= 0:
|
| 380 |
+
continue
|
| 381 |
+
displacement = hypot(late[0] - early[0], late[1] - early[1])
|
| 382 |
+
if displacement / size >= condition.min_displacement_ratio:
|
| 383 |
self.moving_track_last_seen[(label, detection.track_id)] = detection.frame_index
|
| 384 |
return True
|
| 385 |
if label_detections:
|
|
|
|
| 396 |
if detection.track_id is None:
|
| 397 |
continue
|
| 398 |
history = self.track_history.setdefault(detection.track_id, [])
|
| 399 |
+
history.append(
|
| 400 |
+
(detection.frame_index, _foot_point_percent(detection), _box_diagonal_percent(detection))
|
| 401 |
+
)
|
| 402 |
del history[:-10]
|
| 403 |
|
| 404 |
def _mark_cooldowns(self, rule: AutomationRule, timestamp_sec: float) -> None:
|
|
|
|
| 465 |
return max(gap_x, gap_y) * 100.0
|
| 466 |
|
| 467 |
|
| 468 |
+
def _foot_point_percent(detection: Detection) -> tuple[float, float]:
|
| 469 |
+
# Bottom-center ("foot point"): far steadier than the box center for a
|
| 470 |
+
# standing/grounded object, whose top edge flickers with arms/hair/occlusion.
|
| 471 |
+
x1, _y1, x2, y2 = detection.bbox_xyxy_norm
|
| 472 |
+
return ((x1 + x2) * 50.0, y2 * 100.0)
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
def _box_diagonal_percent(detection: Detection) -> float:
|
| 476 |
x1, y1, x2, y2 = detection.bbox_xyxy_norm
|
| 477 |
+
return hypot((x2 - x1) * 100.0, (y2 - y1) * 100.0)
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def _mean_point(points: list[tuple[float, float]]) -> tuple[float, float]:
|
| 481 |
+
count = len(points)
|
| 482 |
+
return (sum(point[0] for point in points) / count, sum(point[1] for point in points) / count)
|
| 483 |
|
| 484 |
|
| 485 |
def _trigger_edge(*, previous: bool, matched: bool) -> Literal["enter", "exit", "while", "none"]:
|
|
@@ -39,6 +39,48 @@ class Detector(Protocol):
|
|
| 39 |
...
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
class UltralyticsYOLOEDetector:
|
| 43 |
def __init__(
|
| 44 |
self,
|
|
@@ -46,6 +88,7 @@ class UltralyticsYOLOEDetector:
|
|
| 46 |
class_names: list[str],
|
| 47 |
model_name: str = "yoloe-26s-seg.pt",
|
| 48 |
device: str | None = None,
|
|
|
|
| 49 |
) -> None:
|
| 50 |
if not class_names:
|
| 51 |
raise ValueError("YOLOE needs at least one open-vocabulary class.")
|
|
@@ -58,6 +101,8 @@ class UltralyticsYOLOEDetector:
|
|
| 58 |
self.class_names = class_names
|
| 59 |
self.model_name = model_name
|
| 60 |
self.device = device
|
|
|
|
|
|
|
| 61 |
self.model = YOLOE(model_name)
|
| 62 |
self.model.set_classes(class_names)
|
| 63 |
|
|
@@ -82,13 +127,16 @@ class UltralyticsYOLOEDetector:
|
|
| 82 |
results = self.model.predict(frame, **kwargs)
|
| 83 |
if not results:
|
| 84 |
return []
|
| 85 |
-
|
| 86 |
results[0],
|
| 87 |
frame_shape=frame.shape,
|
| 88 |
frame_index=frame_index,
|
| 89 |
timestamp_sec=timestamp_sec,
|
| 90 |
fallback_names=self.class_names,
|
| 91 |
)
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
def detections_from_ultralytics_result(
|
|
@@ -107,10 +155,12 @@ def detections_from_ultralytics_result(
|
|
| 107 |
xyxy_values = boxes.xyxy.cpu().tolist()
|
| 108 |
confidences = boxes.conf.cpu().tolist()
|
| 109 |
class_ids = boxes.cls.cpu().tolist()
|
|
|
|
|
|
|
| 110 |
names = getattr(result, "names", {}) or {}
|
| 111 |
|
| 112 |
detections: list[Detection] = []
|
| 113 |
-
for bbox, score, class_id in zip(xyxy_values, confidences, class_ids):
|
| 114 |
label = _label_from_names(names, int(class_id), fallback_names)
|
| 115 |
detections.append(
|
| 116 |
Detection(
|
|
@@ -120,6 +170,7 @@ def detections_from_ultralytics_result(
|
|
| 120 |
confidence=float(score),
|
| 121 |
bbox_xyxy=tuple(float(value) for value in bbox),
|
| 122 |
bbox_xyxy_norm=_normalize_box(bbox, width, height),
|
|
|
|
| 123 |
)
|
| 124 |
)
|
| 125 |
return suppress_duplicate_detections(detections)
|
|
@@ -130,16 +181,32 @@ def suppress_duplicate_detections(
|
|
| 130 |
*,
|
| 131 |
iou_threshold: float = 0.8,
|
| 132 |
) -> list[Detection]:
|
| 133 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
kept: list[Detection] = []
|
| 135 |
for detection in sorted(detections, key=lambda item: item.confidence, reverse=True):
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
)
|
| 141 |
-
if
|
| 142 |
kept.append(detection)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
return sorted(kept, key=lambda item: (item.frame_index, item.label, item.bbox_xyxy_norm))
|
| 144 |
|
| 145 |
|
|
@@ -173,6 +240,13 @@ def _same_label(left: Detection, right: Detection) -> bool:
|
|
| 173 |
return left.label.strip().lower() == right.label.strip().lower()
|
| 174 |
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
def _box_iou(
|
| 177 |
left: tuple[float, float, float, float],
|
| 178 |
right: tuple[float, float, float, float],
|
|
|
|
| 39 |
...
|
| 40 |
|
| 41 |
|
| 42 |
+
class GreedyIoUTracker:
|
| 43 |
+
"""Assign stable track IDs to detections by greedy IoU matching across frames.
|
| 44 |
+
|
| 45 |
+
Detection stays the source of truth: every detection is returned, matched to an
|
| 46 |
+
existing track when their same-label boxes overlap enough, or given a fresh ID
|
| 47 |
+
otherwise. Nothing is dropped for failing to match — unlike a confirm-before-emit
|
| 48 |
+
tracker (e.g. ByteTrack), which withholds unconfirmed detections and lowers recall.
|
| 49 |
+
A track that goes unmatched for more than ``max_age`` frames is forgotten.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
def __init__(self, *, iou_threshold: float = 0.3, max_age: int = 2) -> None:
|
| 53 |
+
self.iou_threshold = iou_threshold
|
| 54 |
+
self.max_age = max_age
|
| 55 |
+
self._tracks: dict[int, dict[str, Any]] = {}
|
| 56 |
+
self._next_id = 1
|
| 57 |
+
|
| 58 |
+
def assign(self, detections: list[Detection]) -> list[Detection]:
|
| 59 |
+
for track in self._tracks.values():
|
| 60 |
+
track["age"] += 1
|
| 61 |
+
assigned: list[Detection] = []
|
| 62 |
+
used: set[int] = set()
|
| 63 |
+
for detection in sorted(detections, key=lambda item: item.confidence, reverse=True):
|
| 64 |
+
label = detection.label.strip().lower()
|
| 65 |
+
best_id: int | None = None
|
| 66 |
+
best_iou = self.iou_threshold
|
| 67 |
+
for track_id, track in self._tracks.items():
|
| 68 |
+
if track_id in used or track["label"] != label:
|
| 69 |
+
continue
|
| 70 |
+
iou = _box_iou(detection.bbox_xyxy_norm, track["bbox"])
|
| 71 |
+
if iou >= best_iou:
|
| 72 |
+
best_iou = iou
|
| 73 |
+
best_id = track_id
|
| 74 |
+
if best_id is None:
|
| 75 |
+
best_id = self._next_id
|
| 76 |
+
self._next_id += 1
|
| 77 |
+
used.add(best_id)
|
| 78 |
+
self._tracks[best_id] = {"bbox": detection.bbox_xyxy_norm, "label": label, "age": 0}
|
| 79 |
+
assigned.append(detection.model_copy(update={"track_id": best_id}))
|
| 80 |
+
self._tracks = {track_id: track for track_id, track in self._tracks.items() if track["age"] <= self.max_age}
|
| 81 |
+
return assigned
|
| 82 |
+
|
| 83 |
+
|
| 84 |
class UltralyticsYOLOEDetector:
|
| 85 |
def __init__(
|
| 86 |
self,
|
|
|
|
| 88 |
class_names: list[str],
|
| 89 |
model_name: str = "yoloe-26s-seg.pt",
|
| 90 |
device: str | None = None,
|
| 91 |
+
tracking_enabled: bool = False,
|
| 92 |
) -> None:
|
| 93 |
if not class_names:
|
| 94 |
raise ValueError("YOLOE needs at least one open-vocabulary class.")
|
|
|
|
| 101 |
self.class_names = class_names
|
| 102 |
self.model_name = model_name
|
| 103 |
self.device = device
|
| 104 |
+
self.tracking_enabled = tracking_enabled
|
| 105 |
+
self._tracker = GreedyIoUTracker() if tracking_enabled else None
|
| 106 |
self.model = YOLOE(model_name)
|
| 107 |
self.model.set_classes(class_names)
|
| 108 |
|
|
|
|
| 127 |
results = self.model.predict(frame, **kwargs)
|
| 128 |
if not results:
|
| 129 |
return []
|
| 130 |
+
detections = detections_from_ultralytics_result(
|
| 131 |
results[0],
|
| 132 |
frame_shape=frame.shape,
|
| 133 |
frame_index=frame_index,
|
| 134 |
timestamp_sec=timestamp_sec,
|
| 135 |
fallback_names=self.class_names,
|
| 136 |
)
|
| 137 |
+
if self._tracker is not None:
|
| 138 |
+
detections = self._tracker.assign(detections)
|
| 139 |
+
return detections
|
| 140 |
|
| 141 |
|
| 142 |
def detections_from_ultralytics_result(
|
|
|
|
| 155 |
xyxy_values = boxes.xyxy.cpu().tolist()
|
| 156 |
confidences = boxes.conf.cpu().tolist()
|
| 157 |
class_ids = boxes.cls.cpu().tolist()
|
| 158 |
+
raw_track_ids = getattr(boxes, "id", None)
|
| 159 |
+
track_ids = raw_track_ids.cpu().tolist() if raw_track_ids is not None else [None] * len(xyxy_values)
|
| 160 |
names = getattr(result, "names", {}) or {}
|
| 161 |
|
| 162 |
detections: list[Detection] = []
|
| 163 |
+
for bbox, score, class_id, track_id in zip(xyxy_values, confidences, class_ids, track_ids):
|
| 164 |
label = _label_from_names(names, int(class_id), fallback_names)
|
| 165 |
detections.append(
|
| 166 |
Detection(
|
|
|
|
| 170 |
confidence=float(score),
|
| 171 |
bbox_xyxy=tuple(float(value) for value in bbox),
|
| 172 |
bbox_xyxy_norm=_normalize_box(bbox, width, height),
|
| 173 |
+
track_id=int(track_id) if track_id is not None else None,
|
| 174 |
)
|
| 175 |
)
|
| 176 |
return suppress_duplicate_detections(detections)
|
|
|
|
| 181 |
*,
|
| 182 |
iou_threshold: float = 0.8,
|
| 183 |
) -> list[Detection]:
|
| 184 |
+
"""Collapse heavily overlapping same-label boxes into one.
|
| 185 |
+
|
| 186 |
+
Keeps the highest-confidence box's geometry/label/score, but carries the
|
| 187 |
+
*oldest* track_id in the overlap group. Ultralytics assigns track IDs from
|
| 188 |
+
an incrementing counter, so the smallest ID is the longest-lived track;
|
| 189 |
+
preferring it keeps identity stable across frames instead of letting it flip
|
| 190 |
+
with per-frame confidence (which otherwise caused cooldown/count misfires).
|
| 191 |
+
"""
|
| 192 |
kept: list[Detection] = []
|
| 193 |
for detection in sorted(detections, key=lambda item: item.confidence, reverse=True):
|
| 194 |
+
match_index = next(
|
| 195 |
+
(
|
| 196 |
+
index
|
| 197 |
+
for index, existing in enumerate(kept)
|
| 198 |
+
if _same_label(detection, existing)
|
| 199 |
+
and _box_iou(detection.bbox_xyxy_norm, existing.bbox_xyxy_norm) >= iou_threshold
|
| 200 |
+
),
|
| 201 |
+
None,
|
| 202 |
)
|
| 203 |
+
if match_index is None:
|
| 204 |
kept.append(detection)
|
| 205 |
+
continue
|
| 206 |
+
existing = kept[match_index]
|
| 207 |
+
oldest_id = _oldest_track_id(existing.track_id, detection.track_id)
|
| 208 |
+
if oldest_id != existing.track_id:
|
| 209 |
+
kept[match_index] = existing.model_copy(update={"track_id": oldest_id})
|
| 210 |
return sorted(kept, key=lambda item: (item.frame_index, item.label, item.bbox_xyxy_norm))
|
| 211 |
|
| 212 |
|
|
|
|
| 240 |
return left.label.strip().lower() == right.label.strip().lower()
|
| 241 |
|
| 242 |
|
| 243 |
+
def _oldest_track_id(left: int | None, right: int | None) -> int | None:
|
| 244 |
+
ids = [value for value in (left, right) if value is not None]
|
| 245 |
+
if not ids:
|
| 246 |
+
return None
|
| 247 |
+
return min(ids)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
def _box_iou(
|
| 251 |
left: tuple[float, float, float, float],
|
| 252 |
right: tuple[float, float, float, float],
|
|
@@ -22,7 +22,7 @@ Use trigger.on="while" only when the user explicitly wants repeated actions whil
|
|
| 22 |
When the request says one object is near, next to, beside, at, by, close to, or in front of another object, you MUST emit a near condition.
|
| 23 |
Do not replace a near relation with two present conditions.
|
| 24 |
Use max_gap_percent for near/far box-edge distance. It is the largest horizontal/vertical edge gap between boxes in normalized frame percent; touching or overlapping boxes have gap 0.
|
| 25 |
-
Use moving for simple same-object
|
| 26 |
Do not generate speed, direction, long-gap re-identification, or trajectory path rules.
|
| 27 |
If the user mentions elapsed time since an action or limiting repeat fires, encode it as gate.cooldown.
|
| 28 |
If the user asks for one action when a condition starts and another action when it stops, use trigger.on="change" and then.enter / then.exit.
|
|
@@ -470,7 +470,7 @@ JSON:
|
|
| 470 |
"name": "car-moving",
|
| 471 |
"when": {{
|
| 472 |
"all": [
|
| 473 |
-
{{"moving": {{"label": "car", "
|
| 474 |
]
|
| 475 |
}},
|
| 476 |
"trigger": {{"on": "enter"}},
|
|
|
|
| 22 |
When the request says one object is near, next to, beside, at, by, close to, or in front of another object, you MUST emit a near condition.
|
| 23 |
Do not replace a near relation with two present conditions.
|
| 24 |
Use max_gap_percent for near/far box-edge distance. It is the largest horizontal/vertical edge gap between boxes in normalized frame percent; touching or overlapping boxes have gap 0.
|
| 25 |
+
Use moving for simple same-object displacement across sampled frames, such as "car moving" or "person walks". Use min_displacement_ratio, default 0.15 (displacement as a fraction of the object's own box size), window_frames, minimum/default 3, and max_missing_frames, default 1.
|
| 26 |
Do not generate speed, direction, long-gap re-identification, or trajectory path rules.
|
| 27 |
If the user mentions elapsed time since an action or limiting repeat fires, encode it as gate.cooldown.
|
| 28 |
If the user asks for one action when a condition starts and another action when it stops, use trigger.on="change" and then.enter / then.exit.
|
|
|
|
| 470 |
"name": "car-moving",
|
| 471 |
"when": {{
|
| 472 |
"all": [
|
| 473 |
+
{{"moving": {{"label": "car", "min_displacement_ratio": 0.15, "window_frames": 3, "max_missing_frames": 1}}}}
|
| 474 |
]
|
| 475 |
}},
|
| 476 |
"trigger": {{"on": "enter"}},
|
|
@@ -1,85 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from dataclasses import dataclass, field
|
| 4 |
-
from math import hypot
|
| 5 |
-
|
| 6 |
-
from .models import Detection
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
def _label(value: str) -> str:
|
| 10 |
-
return value.strip().lower()
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
def _centroid_percent(detection: Detection) -> tuple[float, float]:
|
| 14 |
-
x1, y1, x2, y2 = detection.bbox_xyxy_norm
|
| 15 |
-
return ((x1 + x2) * 50.0, (y1 + y2) * 50.0)
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
@dataclass
|
| 19 |
-
class _Track:
|
| 20 |
-
id: int
|
| 21 |
-
label: str
|
| 22 |
-
centroid: tuple[float, float]
|
| 23 |
-
missed: int = 0
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
class SimpleTracker:
|
| 27 |
-
"""Small label-aware centroid tracker for sampled video detections."""
|
| 28 |
-
|
| 29 |
-
def __init__(self, *, max_match_distance_percent: float = 20.0, max_missed: int = 2) -> None:
|
| 30 |
-
self.max_match_distance_percent = max_match_distance_percent
|
| 31 |
-
self.max_missed = max_missed
|
| 32 |
-
self._next_id = 1
|
| 33 |
-
self._tracks: dict[int, _Track] = {}
|
| 34 |
-
|
| 35 |
-
def update(self, detections: list[Detection]) -> list[Detection]:
|
| 36 |
-
candidates: list[tuple[float, int, int]] = []
|
| 37 |
-
detection_centroids = [_centroid_percent(detection) for detection in detections]
|
| 38 |
-
for detection_index, detection in enumerate(detections):
|
| 39 |
-
label = _label(detection.label)
|
| 40 |
-
centroid = detection_centroids[detection_index]
|
| 41 |
-
for track in self._tracks.values():
|
| 42 |
-
if track.label != label:
|
| 43 |
-
continue
|
| 44 |
-
distance = hypot(centroid[0] - track.centroid[0], centroid[1] - track.centroid[1])
|
| 45 |
-
if distance <= self.max_match_distance_percent:
|
| 46 |
-
candidates.append((distance, detection_index, track.id))
|
| 47 |
-
|
| 48 |
-
assigned_detections: set[int] = set()
|
| 49 |
-
assigned_tracks: set[int] = set()
|
| 50 |
-
active_tracks: set[int] = set()
|
| 51 |
-
detection_track_ids: dict[int, int] = {}
|
| 52 |
-
for _distance, detection_index, track_id in sorted(candidates):
|
| 53 |
-
if detection_index in assigned_detections or track_id in assigned_tracks:
|
| 54 |
-
continue
|
| 55 |
-
assigned_detections.add(detection_index)
|
| 56 |
-
assigned_tracks.add(track_id)
|
| 57 |
-
detection_track_ids[detection_index] = track_id
|
| 58 |
-
|
| 59 |
-
tracked: list[Detection] = []
|
| 60 |
-
for detection_index, detection in enumerate(detections):
|
| 61 |
-
centroid = detection_centroids[detection_index]
|
| 62 |
-
track_id = detection_track_ids.get(detection_index)
|
| 63 |
-
if track_id is None:
|
| 64 |
-
track_id = self._next_id
|
| 65 |
-
self._next_id += 1
|
| 66 |
-
self._tracks[track_id] = _Track(
|
| 67 |
-
id=track_id,
|
| 68 |
-
label=_label(detection.label),
|
| 69 |
-
centroid=centroid,
|
| 70 |
-
)
|
| 71 |
-
else:
|
| 72 |
-
track = self._tracks[track_id]
|
| 73 |
-
track.centroid = centroid
|
| 74 |
-
track.missed = 0
|
| 75 |
-
active_tracks.add(track_id)
|
| 76 |
-
tracked.append(detection.model_copy(update={"track_id": track_id}))
|
| 77 |
-
|
| 78 |
-
for track_id, track in list(self._tracks.items()):
|
| 79 |
-
if track_id in active_tracks:
|
| 80 |
-
continue
|
| 81 |
-
track.missed += 1
|
| 82 |
-
if track.missed > self.max_missed:
|
| 83 |
-
del self._tracks[track_id]
|
| 84 |
-
|
| 85 |
-
return tracked
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,20 +1,22 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import hashlib
|
|
|
|
| 4 |
import shutil
|
| 5 |
import subprocess
|
| 6 |
import tempfile
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Callable
|
| 9 |
from uuid import uuid4
|
| 10 |
|
| 11 |
from .detector import Detector, UltralyticsYOLOEDetector, parse_class_prompt, suppress_duplicate_detections
|
| 12 |
from .models import ActionEvent, Detection, FrameSample, VideoProcessResult
|
| 13 |
-
from .tracking import SimpleTracker
|
| 14 |
|
| 15 |
|
| 16 |
ProgressCallback = Callable[[int, int | None], None]
|
| 17 |
MP4_CODEC = "mp4v"
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
def process_video(
|
|
@@ -29,6 +31,7 @@ def process_video(
|
|
| 29 |
image_size: int | None = None,
|
| 30 |
device: str | None = None,
|
| 31 |
max_detections: int | None = None,
|
|
|
|
| 32 |
detector: Detector | None = None,
|
| 33 |
output_dir: str | None = None,
|
| 34 |
progress: ProgressCallback | None = None,
|
|
@@ -53,7 +56,22 @@ def process_video(
|
|
| 53 |
if max_detections is not None and max_detections < 1:
|
| 54 |
raise ValueError("max_detections must be at least 1.")
|
| 55 |
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
capture = cv2.VideoCapture(video_path)
|
| 58 |
if not capture.isOpened():
|
| 59 |
raise ValueError(f"Could not open video: {video_path}")
|
|
@@ -76,10 +94,18 @@ def process_video(
|
|
| 76 |
|
| 77 |
detections: list[Detection] = []
|
| 78 |
frames: list[FrameSample] = []
|
| 79 |
-
tracker = SimpleTracker()
|
| 80 |
processed_frames = 0
|
| 81 |
frame_index = -1
|
| 82 |
latest_detections: list[Detection] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
try:
|
| 84 |
while True:
|
| 85 |
ok, frame = capture.read()
|
|
@@ -91,8 +117,16 @@ def process_video(
|
|
| 91 |
break
|
| 92 |
timestamp_sec = frame_index / source_fps
|
| 93 |
frames.append(FrameSample(frame_index=frame_index, timestamp_sec=timestamp_sec))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
frame_detections = detector.detect(
|
| 95 |
-
frame,
|
| 96 |
frame_index=frame_index,
|
| 97 |
timestamp_sec=timestamp_sec,
|
| 98 |
confidence=confidence,
|
|
@@ -100,9 +134,18 @@ def process_video(
|
|
| 100 |
max_detections=max_detections,
|
| 101 |
)
|
| 102 |
frame_detections = suppress_duplicate_detections(frame_detections)
|
| 103 |
-
latest_detections =
|
| 104 |
detections.extend(latest_detections)
|
| 105 |
processed_frames += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
if progress:
|
| 107 |
progress(processed_frames, max_frames)
|
| 108 |
|
|
@@ -111,7 +154,14 @@ def process_video(
|
|
| 111 |
finally:
|
| 112 |
writer.release()
|
| 113 |
capture.release()
|
|
|
|
| 114 |
_finalize_browser_mp4(output_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
return VideoProcessResult(
|
| 117 |
output_video_path=str(output_path),
|
|
@@ -175,6 +225,13 @@ def render_automation_video(
|
|
| 175 |
frame_index = -1
|
| 176 |
latest_detections: list[Detection] = []
|
| 177 |
latest_events: list[ActionEvent] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
try:
|
| 179 |
while True:
|
| 180 |
ok, frame = capture.read()
|
|
@@ -195,7 +252,9 @@ def render_automation_video(
|
|
| 195 |
finally:
|
| 196 |
writer.release()
|
| 197 |
capture.release()
|
|
|
|
| 198 |
_finalize_browser_mp4(output_path)
|
|
|
|
| 199 |
|
| 200 |
return str(output_path)
|
| 201 |
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import hashlib
|
| 4 |
+
import logging
|
| 5 |
import shutil
|
| 6 |
import subprocess
|
| 7 |
import tempfile
|
| 8 |
+
import time
|
| 9 |
from pathlib import Path
|
| 10 |
from typing import Callable
|
| 11 |
from uuid import uuid4
|
| 12 |
|
| 13 |
from .detector import Detector, UltralyticsYOLOEDetector, parse_class_prompt, suppress_duplicate_detections
|
| 14 |
from .models import ActionEvent, Detection, FrameSample, VideoProcessResult
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
ProgressCallback = Callable[[int, int | None], None]
|
| 18 |
MP4_CODEC = "mp4v"
|
| 19 |
+
LOGGER = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
|
| 22 |
def process_video(
|
|
|
|
| 31 |
image_size: int | None = None,
|
| 32 |
device: str | None = None,
|
| 33 |
max_detections: int | None = None,
|
| 34 |
+
tracking_enabled: bool = False,
|
| 35 |
detector: Detector | None = None,
|
| 36 |
output_dir: str | None = None,
|
| 37 |
progress: ProgressCallback | None = None,
|
|
|
|
| 56 |
if max_detections is not None and max_detections < 1:
|
| 57 |
raise ValueError("max_detections must be at least 1.")
|
| 58 |
|
| 59 |
+
if detector is None:
|
| 60 |
+
LOGGER.info(
|
| 61 |
+
"Loading detector model=%s classes=%s device=%s tracking=%s",
|
| 62 |
+
model_name,
|
| 63 |
+
", ".join(classes),
|
| 64 |
+
device or "auto",
|
| 65 |
+
tracking_enabled,
|
| 66 |
+
)
|
| 67 |
+
detector_started = time.perf_counter()
|
| 68 |
+
detector = UltralyticsYOLOEDetector(
|
| 69 |
+
class_names=classes,
|
| 70 |
+
model_name=model_name,
|
| 71 |
+
device=device or None,
|
| 72 |
+
tracking_enabled=tracking_enabled,
|
| 73 |
+
)
|
| 74 |
+
LOGGER.info("Detector loaded in %.2fs", time.perf_counter() - detector_started)
|
| 75 |
capture = cv2.VideoCapture(video_path)
|
| 76 |
if not capture.isOpened():
|
| 77 |
raise ValueError(f"Could not open video: {video_path}")
|
|
|
|
| 94 |
|
| 95 |
detections: list[Detection] = []
|
| 96 |
frames: list[FrameSample] = []
|
|
|
|
| 97 |
processed_frames = 0
|
| 98 |
frame_index = -1
|
| 99 |
latest_detections: list[Detection] = []
|
| 100 |
+
LOGGER.info(
|
| 101 |
+
"Processing video=%s fps=%.2f size=%sx%s sample_stride=%s max_frames=%s",
|
| 102 |
+
video_path,
|
| 103 |
+
source_fps,
|
| 104 |
+
width,
|
| 105 |
+
height,
|
| 106 |
+
effective_frame_stride,
|
| 107 |
+
max_frames,
|
| 108 |
+
)
|
| 109 |
try:
|
| 110 |
while True:
|
| 111 |
ok, frame = capture.read()
|
|
|
|
| 117 |
break
|
| 118 |
timestamp_sec = frame_index / source_fps
|
| 119 |
frames.append(FrameSample(frame_index=frame_index, timestamp_sec=timestamp_sec))
|
| 120 |
+
LOGGER.info(
|
| 121 |
+
"Detecting sampled frame %s/%s source_frame=%s timestamp=%.2fs",
|
| 122 |
+
processed_frames + 1,
|
| 123 |
+
max_frames,
|
| 124 |
+
frame_index,
|
| 125 |
+
timestamp_sec,
|
| 126 |
+
)
|
| 127 |
+
detect_started = time.perf_counter()
|
| 128 |
frame_detections = detector.detect(
|
| 129 |
+
frame.copy(),
|
| 130 |
frame_index=frame_index,
|
| 131 |
timestamp_sec=timestamp_sec,
|
| 132 |
confidence=confidence,
|
|
|
|
| 134 |
max_detections=max_detections,
|
| 135 |
)
|
| 136 |
frame_detections = suppress_duplicate_detections(frame_detections)
|
| 137 |
+
latest_detections = frame_detections
|
| 138 |
detections.extend(latest_detections)
|
| 139 |
processed_frames += 1
|
| 140 |
+
tracked_count = sum(1 for detection in latest_detections if detection.track_id is not None)
|
| 141 |
+
LOGGER.info(
|
| 142 |
+
"Detected sampled frame %s/%s in %.2fs: detections=%s tracked=%s",
|
| 143 |
+
processed_frames,
|
| 144 |
+
max_frames,
|
| 145 |
+
time.perf_counter() - detect_started,
|
| 146 |
+
len(latest_detections),
|
| 147 |
+
tracked_count,
|
| 148 |
+
)
|
| 149 |
if progress:
|
| 150 |
progress(processed_frames, max_frames)
|
| 151 |
|
|
|
|
| 154 |
finally:
|
| 155 |
writer.release()
|
| 156 |
capture.release()
|
| 157 |
+
LOGGER.info("Finalizing annotated video %s", output_path)
|
| 158 |
_finalize_browser_mp4(output_path)
|
| 159 |
+
LOGGER.info(
|
| 160 |
+
"Finished video processing: sampled_frames=%s detections=%s output=%s",
|
| 161 |
+
processed_frames,
|
| 162 |
+
len(detections),
|
| 163 |
+
output_path,
|
| 164 |
+
)
|
| 165 |
|
| 166 |
return VideoProcessResult(
|
| 167 |
output_video_path=str(output_path),
|
|
|
|
| 225 |
frame_index = -1
|
| 226 |
latest_detections: list[Detection] = []
|
| 227 |
latest_events: list[ActionEvent] = []
|
| 228 |
+
LOGGER.info(
|
| 229 |
+
"Rendering automation video=%s fps=%.2f sample_stride=%s max_frames=%s",
|
| 230 |
+
source_video_path,
|
| 231 |
+
source_fps,
|
| 232 |
+
effective_frame_stride,
|
| 233 |
+
max_frames,
|
| 234 |
+
)
|
| 235 |
try:
|
| 236 |
while True:
|
| 237 |
ok, frame = capture.read()
|
|
|
|
| 252 |
finally:
|
| 253 |
writer.release()
|
| 254 |
capture.release()
|
| 255 |
+
LOGGER.info("Finalizing automation video %s", output_path)
|
| 256 |
_finalize_browser_mp4(output_path)
|
| 257 |
+
LOGGER.info("Finished automation render: output=%s", output_path)
|
| 258 |
|
| 259 |
return str(output_path)
|
| 260 |
|