Javier Montalvo Claude Opus 4.8 (1M context) commited on
Commit
114ea19
·
1 Parent(s): 8536e24

Decouple tracking from detection; size-relative motion; UI tuning

Browse files

Engine:
- 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 CHANGED
@@ -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 lightweight same-label tracking across sampled frames;
62
- it requires at least three tracked observations and tolerates one missed sampled
63
- frame by default. Speed, direction, long-gap re-identification, and trajectory
64
- paths are not supported yet.
 
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
 
README.md CHANGED
@@ -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 lightweight same-label centroid tracking across
92
- sampled frames and tolerates one missed sampled frame by default to avoid repeat
93
- alerts from detector flicker. Gates include enabled state and cooldown. Triggers
94
- can fire while a condition is true, when it becomes true, when it becomes false,
95
- or on either change. Tiny Trigger does not yet support speed, direction, long-gap
 
 
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
frontend/dist/assets/{index-CLWJR9Mi.js → index-CiwitQvv.js} RENAMED
The diff for this file is too large to render. See raw diff
 
frontend/dist/assets/index-DbWTYGKW.css ADDED
@@ -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))}}
frontend/dist/assets/index-DpGaJEG3.css DELETED
@@ -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))}}
 
 
frontend/dist/index.html CHANGED
@@ -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-CLWJR9Mi.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-DpGaJEG3.css">
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>
frontend/src/lib/dashboard.tsx CHANGED
@@ -19,7 +19,7 @@ import type {
19
  ValidationResult,
20
  } from "./types"
21
 
22
- const DEFAULT_CLASSES = "person, cat, dog, car, chair, cup"
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: 1.0,
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,
frontend/src/lib/types.ts CHANGED
@@ -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 {
frontend/src/modules/detector/DetectorPanel.tsx CHANGED
@@ -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, dog, car, chair, cup…"
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(1)}s`}
151
- min={0.1}
152
  max={5}
153
- step={0.1}
154
- onChange={(v) => setParam("sampleIntervalSec", Number(v.toFixed(1)))}
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"
frontend/src/modules/rules/RuleStudioPanel.tsx CHANGED
@@ -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 && validation.rules.length > 0 ? (
131
  <div className="space-y-2">
132
- {validation.rules.map((r) => (
 
 
 
 
 
 
 
 
 
 
133
  <div
134
  key={r.name}
135
- className="flex flex-col gap-3 rounded-lg border border-border bg-black/20 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
136
  >
137
- <div className="min-w-0">
138
- <div className="flex items-center gap-2">
139
- <span
140
- className={cn(
141
- "size-1.5 rounded-full",
142
- r.enabled ? "bg-primary" : "bg-muted-foreground/40",
143
- )}
144
- />
145
- <span className="truncate font-mono text-sm text-foreground">{r.name}</span>
146
- {r.name.startsWith("example-") && <Badge variant="default">example</Badge>}
147
- <Badge variant="default">{r.trigger}</Badge>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  </div>
149
- <div className="mt-1 pl-3.5 text-xs text-muted-foreground">
150
- {r.conditions} condition{r.conditions === 1 ? "" : "s"} →{" "}
151
- {r.actions.map((a) => `${a.type}:${a.name}`).join(", ")}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  </div>
153
- {r.labels.length > 0 && (
154
- <div className="mt-2 flex flex-wrap gap-1 pl-3.5">
155
- {r.labels.map((label) => (
156
- <span
157
- key={label}
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
- <Button
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
+ }
server.py CHANGED
@@ -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:
tests/test_automation.py CHANGED
@@ -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", "min_displacement_percent": 3, "window_frames": 3}}
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
- "min_displacement_percent": 3,
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
  }
tests/test_detector.py CHANGED
@@ -1,9 +1,44 @@
1
  from __future__ import annotations
2
 
3
- from tiny_trigger.detector import parse_class_prompt, suppress_duplicate_detections
 
 
 
 
 
 
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
tests/test_server.py CHANGED
@@ -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)
tests/test_tracking.py DELETED
@@ -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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_video.py CHANGED
@@ -34,7 +34,7 @@ class FakeDetector:
34
  ]
35
 
36
 
37
- class MovingDetector:
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 test_process_video_assigns_track_ids(tmp_path: Path) -> None:
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=MovingDetector(),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  output_dir=str(tmp_path),
134
  )
135
 
136
- assert [item.track_id for item in result.detections] == [1, 1, 1]
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:
tiny_trigger/__init__.py CHANGED
@@ -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",
tiny_trigger/automation.py CHANGED
@@ -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
- min_displacement_percent: float = Field(default=3.0, ge=0.0)
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
- _first_frame, first_centroid = history[-condition.window_frames]
370
- _last_frame, last_centroid = history[-1]
371
- displacement = hypot(
372
- last_centroid[0] - first_centroid[0],
373
- last_centroid[1] - first_centroid[1],
374
- )
375
- if displacement >= condition.min_displacement_percent:
 
 
 
 
 
 
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((detection.frame_index, _centroid_percent(detection)))
 
 
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 _centroid_percent(detection: Detection) -> tuple[float, float]:
 
 
 
 
 
 
 
460
  x1, y1, x2, y2 = detection.bbox_xyxy_norm
461
- return ((x1 + x2) * 50.0, (y1 + y2) * 50.0)
 
 
 
 
 
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"]:
tiny_trigger/detector.py CHANGED
@@ -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
- return detections_from_ultralytics_result(
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
- """Keep the highest-confidence same-label box for heavily overlapping detections."""
 
 
 
 
 
 
 
134
  kept: list[Detection] = []
135
  for detection in sorted(detections, key=lambda item: item.confidence, reverse=True):
136
- duplicate = any(
137
- _same_label(detection, existing)
138
- and _box_iou(detection.bbox_xyxy_norm, existing.bbox_xyxy_norm) >= iou_threshold
139
- for existing in kept
 
 
 
 
140
  )
141
- if not duplicate:
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],
tiny_trigger/llm.py CHANGED
@@ -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 centroid displacement across sampled frames, such as "car moving" or "person walks". Use min_displacement_percent, default 3, 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,7 +470,7 @@ JSON:
470
  "name": "car-moving",
471
  "when": {{
472
  "all": [
473
- {{"moving": {{"label": "car", "min_displacement_percent": 3, "window_frames": 3, "max_missing_frames": 1}}}}
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"}},
tiny_trigger/tracking.py DELETED
@@ -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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tiny_trigger/video.py CHANGED
@@ -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
- detector = detector or UltralyticsYOLOEDetector(class_names=classes, model_name=model_name, device=device or None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = tracker.update(frame_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