This commit is contained in:
2026-09-09 21:44:05 -05:00
commit 2998b478ca
74 changed files with 139639 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
oko
Copyright (C) 2026 nolan
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
oko Copyright (C) 2026 nolan
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
+3
View File
@@ -0,0 +1,3 @@
# oko
The full oko stack
+280
View File
@@ -0,0 +1,280 @@
# TODO — Security audit & performance review (2026-08-23)
Findings ordered by severity/impact. Line references verified against working tree.
Context: pipeline is runCam → mofin → terp → coordinator (+ livestream-cache side channel),
all communicating via plaintext gob over TCP. Ports assume LAN/Tailscale isolation today;
every finding marked [NET] matters the moment any port is reachable beyond localhost.
---
## SECURITY
### Critical
**S1. Path traversal → arbitrary file write in coordinator** [NET]
`coordinator/main.go` `handleClip`: `user, camera := splitSource(clp.SourceData)` takes
attacker-controlled strings straight into
`filepath.Join(storageDir, user, camera, dateShard, dirName)` then `MkdirAll` +
writes `clip.mp4`/`clip.json`.
A crafted `SourceData` such as `"../../../tmp--evil"` escapes the storage root and writes
an attacker-controlled MP4 (raw pixel bytes) outside it. Even benign-looking names with
`/` create unintended nested dirs (the test suite itself uses camera name containing `/`).
Fix: validate both fields against `^[A-Za-z0-9._-]+$` (reject otherwise), reject empty,
and add a final `strings.HasPrefix(finalPath, storageRoot+sep)` assertion.
**S2. Remote process-crash DoS across the whole pipeline** [NET]
Ingest paths never validate clip internal consistency (`CheckLenCorrelations()` exists in
frame/frame.go but is called nowhere except tests):
- `coordinator/main.go insertClip/dirName`: indexes `clp.Guids[0]`, `clp.Timestamps[0]`,
`clp.PixelMats[0]` unchecked → panic.
- `mofin/main.go``frame.CountChangedPixels`: loops `i < len(PixelMats)-1` indexing
`c.Guids[i]` → short `Guids` slice panics.
Any malformed gob Clip crashes the entire receiving process (panic in handler goroutine =
process exit). One bad client kills surveillance for all cameras.
Fix: call `clip.CheckLenCorrelations()` immediately after every decode in mofin ingest,
terp ingest, coordinator handleClip, livestream-cache handleTCPConnection; drop + log on
failure. Also wrap handlers with `recover()` as defense-in-depth.
**S3. TLS private key committed to git**
`livestream-cache/key.pem` (+ `cert.pem`) tracked in history. Regenerate, move out of repo,
gitignore, and treat the old key as burned (anyone with repo history can MITM the :8443
viewer traffic).
### High
**S4. No transport encryption or authentication on any pipeline hop** [NET]
mofin (:8083), terp (:8081), coordinator (:8082), livestream-cache ingest (:8085) accept
plaintext gob from anyone who can reach the port, binding all interfaces (`":8083"`-style
addresses, not `127.0.0.1`). Consequences: forged footage injected into any stage, live
view poisoning, clip replay/spoofing, free reconnaissance of camera names via logged
SourceData. Only livestream-cache's *viewer* port has TLS — and no auth either (see S5).
Fix options (pick one):
a. Default binds to `127.0.0.1` + explicit opt-in interface config (cheapest);
b. Shared-token handshake as first gob message on each conn (cheap, works over Tailscale);
c. Full mTLS mirroring the existing cert setup (strongest).
**S5. Unauthenticated MJPEG viewing**
`handleMJPEGStream` checks only that `sourceData` exists in `cameraUsers`. Anyone reaching
:8443 watches every camera feed. Add HTTP BasicAuth (bcrypt-hashed creds in YAML) or a
bearer token per viewer; return 401 before touching the stream.
**S6. Unbounded gob decode → memory-exhaustion DoS** [NET]
Every listener decodes attacker-sized `[][]byte` payloads with no cap (a single Clip can
declare gigabytes of pixel data → OOM). Combined with S7 this is trivially scriptable.
Fix: enforce a max-clip-bytes config; simplest robust route is `io.LimitReader`-wrapped
conn + length-prefixed framing, or decode then reject if `len(PixelMats) > MaxFrames` /
`len(frame) > MaxFrameBytes` *before* any processing/allocation beyond the decode itself.
**S7. No connection deadlines anywhere (slowloris / goroutine exhaustion)** [NET]
Zero `SetDeadline` calls repo-wide (verified). Servers hold one goroutine + buffers per
client indefinitely; a handful of idle sockets starves the process. Also client-side:
`Clip.Send` has DialTimeout but no write deadline, and `flush()` in runCam calls it inline
— a stuck receiver freezes that camera's clip building.
Fix: `conn.SetDeadline(time.Now().Add(N))` after accept and refresh per message; write
deadline around every send including `Clip.Send`.
### Medium
**S8. Passphrases exposed via CLI flags and plaintext configs**
`-passphrase` flags appear in `ps`/`/proc/*/cmdline` for every local user and persist in
shell history; YAML configs store them in cleartext. Fix: support env var + `-passfile`
(or keyring prompt), never echo back; document migration.
Related landmine: legacy `frame.ConsumeMat`/`preframeWrapper.ToFrame` concatenate
`CfgData.PassHash` INTO `SourceData` — if ever rewired, password hashes leak into clip.json
metadata and DB rows. Delete the hash-from-metadata behavior now while the code is dead.
**S9. Metadata travels in cleartext even when frames are encrypted**
AES-GCM covers `PixelBytes` only; SourceData (username/camera), GUIDs, timestamps,
detections, and Comparisons ride plaintext gob. Network observer learns which cameras,
when, and how active they are. Fix: extend encryption to the serialized metadata block
(e.g., encrypt a marshalled header alongside pixels) or tunnel the whole gob stream in TLS
(S4c solves this wholesale).
**S10. Livestream-cache trusts whatever arrives for a registered camera** [NET]
If a camera is registered without a passphrase, any host can inject frames into the live
view (no origin concept exists). Subsumed by S4b/S4c, but worth an explicit note: the cache
should reject conns lacking the shared secret even when passphrases are unused.
**S11. Hardened servers missing timeouts / limits (HTTPS viewer)**
`http.Server` created without `ReadHeaderTimeout`, `IdleTimeout`, `MaxHeaderBytes`;
unlimited concurrent streams. Slowloris applies here too. Add sane timeouts + a
semaphore-capped stream count.
**S12. Footage written world-readable**
Storage dirs/files default 0755/0644 (`MkdirAll`, VideoWriter output). On multi-user hosts
any local account can copy surveillance footage. Write with 0750/0640 (umask or explicit
Chmod after MkdirAll).
**S13. MySQL DSN built via fmt.Sprintf**
Special characters in db-user/password silently corrupt the DSN (and error messages may
echo it). Use `mysql.Config{...}.FormatDSN()`; load creds from env/file rather than flags.
### Low
- **S14 Log injection**: raw request paths/hosts flow into log lines (livestream-cache,
coordinator); sanitize/strip control chars.
- **S15 Git remote over plain HTTP** (`http://gitea:3000/...`) — switch to SSH or HTTPS
for push traffic on shared LANs.
- **S16 oko-run flag interpolation** builds child-process args from unquoted map values —
harmless today (config is trusted, exec doesn't use a shell) but easy to misuse; quote
values explicitly.
- **S17 `Frame.Errors` never populated** — no audit trail for dropped/failed frames;
populate at capture time (decode failures, drops-on-full-channel) so downstream can
report data-quality issues.
---
## PERFORMANCE
Ordered by expected impact on the target hardware (Pi Zero W-class).
**P1. PBKDF2 with 600k iterations executed PER FRAME**`frame.Encrypt`
(frame/frame.go). A 10 s clip at ~50 fps = ~500 KDF derivations per clip, each ~100 ms+
on a Pi → encryption dominates total CPU and burns through most of the clip interval.
Fix: derive ONE key per clip — move salt to Clip level (add `Salt []byte` field, derive
once in mofin, encrypt every frame with distinct random nonces). Backward-compatible
enough since all stages deploy together. Expected: ~99% reduction in KDF cost.
**P2. Haar cascade XML re-loaded for every frame, for every location**
`frame.Detect` (frame/detect.go:40-43) constructs `gocv.NewCascadeClassifier` + `.Load()`
per frame per model path. XML parsing dwarfs actual detection cost.
Fix: load all classifiers once at terp startup, share the objects across worker
goroutines (`detectMultiScale` is thread-safe for reads). Expected: order-of-magnitude
speedup of the classification stage.
**P3. Raw, uncompressed pixels end-to-end** — 320×240×3 B ≈ 230 KB/frame; observed clip =
~500 frames ⇒ ~115 MB per clip held in RAM (builder buffer + gob encode buffer + decode
side), plus 115 MB bursts on the wire every 10 s per camera. On a Zero W this is the
memory ceiling. Options in ascending effort:
a. Cap clip duration/fps for the motion stage (e.g. analyze 5 fps subsample);
b. Per-frame JPEG (or MJPEG stream) on the wire — ~1020× size reduction, decode cost
paid once in mofin;
c. H.264 chunk streaming (matches what coordinator re-encodes anyway).
At minimum document the footprint math near the clip builder.
**P4. Motion analysis done TWICE** — mofin computes `CountChangedPixels` and stores it in
`clp.Comparisons`; terp then recomputes identical values (terp/main.go classify loop)
instead of trusting `clp.Comparisons` when present. Free 2× saving on the AbsDiff stage:
`if len(clp.Comparisons) > 0 { skip }`.
**P5. mofin decrypts then re-encrypts every frame** (fresh salt each time, defeating any
KDF caching) purely to run motion detection on plaintext. With P1 fixed the re-encrypt is
cheap, but better: move motion gating upstream into runCam (pre-send) and ship encrypted
clips untouched through mofin, or have mofin operate on a small thumbnail sub-stream
(P3a) so bulk pixels never decrypt/re-encrypt.
**P6. Livestream JPEG re-encoded per viewer per tick**`handleMJPEGStream` encodes a
fresh JPEG from the cached RGBA image for EVERY connected client EVERY 100 ms. N viewers
= N× encode cost. Fix: cache `[]byte` JPEG per camera; re-encode only when a new frame
lands (janitor/ingest hook), serve the same bytes to all viewers.
**P7. Pure-Go BGR→RGBA pixel loop in frameToImage** — per-pixel Go loop with bounds
checks per frame. Since gocv is already in the module graph, replace the whole helper
with `gocv.Mat` construction from bytes + `gocv.IMencode(".jpg", mat, &buf)` — moves
color conversion AND compression into optimized OpenCV code in one step (subsumes P6's
encoder too).
**P8. Motion comparison allocates heavily per frame-pair**`compareTo` creates 3 Mats
(diff/gray/thresh) and converts overlapping frames to Mat twice (frame i used as pair i-1
tail and pair i head). Fix inside `CountChangedPixels`: keep previous frame's gray Mat
alive between iterations, reuse scratch Mats with `Mat.CopyTo`/in-place ops, and downscale
to e.g. 96×72 grayscale before diffing (motion gating needs neither resolution nor color).
Expected: 1030× less work in the hottest OpenCV section.
**P9. Unbounded goroutine-per-connection/per-clip** in mofin, terp, coordinator handlers.
One burst of clips = unbounded concurrent 100 MB-scale allocations (compounds S6/P3).
Cap with a buffered-channel semaphore (configurable workers, default 24) per service.
**P10. DB writes row-at-a-time**`insertClip` Execs one INSERT per detection. Prepare
once per connection + single transaction per clip; enable go-sql-driver interpolateParams
for small batches. Matters when detections-per-clip grows (YOLO wiring will multiply it).
**P11. VideoWriter codec fallback ends at MJPG** — MJPG fallback produces files ~510×
larger than mp4v/H264. Prefer trying `avc1 → mp4v → MJPG`, and make codec/fourcc
configurable per deployment. Also `computeFPS` uses only the first frame delta — average
over several deltas to avoid wildly wrong timestamps after capture hiccups.
**P12. Dev-loop: oko-run rebuild check stats binary existence only** — stale binaries are
silently reused after source edits. Compare mtimes (newest .go vs binary) or add a
`-force-rebuild` flag.
---
## COMPUTER-VISION PIPELINE ENHANCEMENTS
Grouped: correctness first, then accuracy, then capability.
### Correctness (do these before trusting any detections)
**C1. YOLO head parsed wrong — class information discarded.**
`DetectYolo` reads only the box/conf channels; ONNX YOLOv8/v11 export layout is
[1, 84, N] = cx,cy,w,h + 80 class scores, so every detection currently shares one title
and confidence source. Fix: transpose to [N,84], take argmax class + its score as the
detection, map through a COCO names table, filter by per-class thresholds.
**C2. NMS never applied to YOLO output.** `filterNMS` exists but is unwired — expect
dozens of duplicate boxes per object. Apply class-aware NMS (IoU ~0.45) after C1.
**C3. Model loaded per call in DetectYolo path** (`loadModel` per invocation). Same fix
as P2: load Net once at startup; `net.Empty()`/forward is the only per-frame op.
### Accuracy
**C4. Replace Haar fullbody primary detection.** Haar at 320×240 yields heavy false
positives/negatives. Better ladder: (a) HOG+SVM person detector as cheap upgrade;
(b) a lightweight DNN detector (MobileNet-SSD / nano-YOLO) reusing the existing ONNX
plumbing — the infrastructure lands with C1C3 anyway.
**C5. Background subtraction instead of consecutive-frame diff.**
`gocv.BackgroundSubtractorMOG2` gives: robustness to gradual lighting changes, built-in
shadow suppression, contour masks for area/centroid features (replacing raw changed-pixel
counts), and long-term stationary-object suppression. Keep frame-diff as fallback for the
first seconds after startup (model warm-up).
**C6. Clean up the motion signal.** Before/after diffing: Gaussian blur (σ≈3) to kill
sensor noise; morphological open+close on the threshold mask; minimum-contour-area gate;
auto-calibrated threshold per camera from rolling idle-period noise statistics (mean+3σ of
changed pixels) instead of magic constant 30.
**C7. ROI masking per camera.** Configurable polygon per view (sky/hedges/road excluded)
applied to the motion mask — eliminates the classic swaying-trees/headlights false alarms.
### Capability
**C8. Event-shaped recording.** Today fixed 10 s ticker slices cut events mid-action and
record dead air. Switch clip builder to: rolling ring buffer (~5 s pre-roll) + trigger on
motion + record until quiet for N s (+ hysteresis), emitting variable-length event clips.
Biggest UX win per line of code.
**C9. Intra-clip tracking.** IoU-match detections across frames within a clip → stable
object IDs, direction, dwell time; persist track summaries to MySQL next to detections.
Enables "person loitered 4 min" queries and dedupes alert storms.
**C10. Quality gates.** Variance-of-Laplacian blur check per captured frame; discard
garbage (exposure glitches) before they poison motion stats; populate `Frame.Errors`
(S17) with reasons for observability.
**C11. Review affordances at coordinator:** save first-detection-frame JPEG thumbnails +
detection crops beside clip.mp4; add a tiny JSON index endpoint for a future web UI.
Cheap, transforms triage from "watch every mp4" to scanning a contact sheet.
**C12. Retention/GC job.** Storage grows unbounded (only janitor is the live-view cache).
Age-based pruning + total-quota enforcement in coordinator, config-driven.
**C13. Notification hook.** After terp classification, POST event summaries (camera, class,
track info, thumbnail ref) to a webhook/Ntfy/MQTT — turns the pipeline from recorder into
alarm system.
**C14. Cross-camera correlation (later).** Time-windowed matching of tracks across cameras
(same user namespace) for entry/exit reasoning.
---
## Suggested execution order
Quick wins first (hours): S2 validation+recover, S1 traversal guard, S5 basic auth,
P4 (one-liner), P2 (classifier reuse), P6/P7 (jpeg cache + IMencode).
Then structural (days): P1 KDF-per-clip, S4b token auth, S6/S7 deadlines+caps,
P8 motion-buffer reuse, C1+C2+C3 YOLO fix.
Then feature work: C8 event recording, C5 background subtraction, C9 tracking,
P3 compression decision (needs a bandwidth/storage budget discussion).
Binary file not shown.
+278
View File
@@ -0,0 +1,278 @@
# Security Audit — oko livestream/camera pipeline
**Audited scope:** all Go modules (`frame`, `runCam`, `mofin`, `terp`, `coordinator`, `livestream-cache`, `oko-run`), shell/launcher scripts, YAML configs, and everything tracked in the git repository.
**Date:** 2026-08-28
**Method:** full source review + targeted exploit verification (path traversal, malformed-clip crash, committed-secret history scan).
---
## Executive summary
The oko pipeline moves live/captured camera frames over plaintext TCP between never-authenticated daemons. Anyone who can reach any of the default `:808x`/`:8443` listeners can inject video clips, poison live feeds, crash daemons, or read live footage. The most severe problems are not crypto weaknesses but architectural ones: **no authentication anywhere, no input validation on anything received from the network, no resource limits, and secrets/footage committed to git.**
**Verified during this audit:**
- A single crafted TCP clip crashes `coordinator`, `mofin`, and `terp` (index-out-of-range panic in `Clip.Sublimate`).
- An attacker-controlled `SourceData` value lets a clip escape the storage directory (path traversal to arbitrary file writes).
- TLS private key + real surveillance footage are present in git history.
---
## Findings (most → least serious)
### 1. CRITICAL — No authentication on any network endpoint; all listeners bind `0.0.0.0`
**Location:**
- `mofin/main.go:28,41,54-73` — listener, default `:8083`
- `terp/main.go:27,47,59-102` — listener, default `:8081`
- `coordinator/main.go:41,86,98-113` — listener, default `:8082`
- `livestream-cache/main.go:256-325` — TCP ingest on `:8085`
- `livestream-cache/main.go:327-347` — HTTPS MJPEG server on `:8443`
- `run-stack.sh:58-87` — launches everything bound to all interfaces
**Description:** Every daemon listens on `:port` (all interfaces) and accepts any TCP connection. There is no token, no shared secret handshake, no TLS client-auth, no IP allowlist — nothing. The config comments ("over Tailscale") imply the intended boundary, but nothing enforces it; the ports are also reachable on any other interface (LAN, Docker bridges, etc.).
**Impact:**
- Remote clip injection: arbitrary video can be routed into the motion-detection / classifier / storage pipeline.
- Live-feed poisoning: spoofed frames overwrite legitimate cameras in the livestream cache (`livestream-cache/main.go:299-321`), so a viewer sees attacker-chosen images on a real camera stream.
- DoS: malicious peers can crash daemons and exhaust resources (findings #2, #3, #5).
- Data theft: if any listener is exposed, an attacker gets fuller access than the camera operator intends.
**Recommendation:** Require authentication on every ingest endpoint (HMAC-authenticated messages or an API token), bind to loopback/Tailscale interface explicitly (configurable per service), and consider TLS with client certificates for inter-node traffic.
---
### 2. CRITICAL — Remotely-triggerable crash: malformed Clip causes index-out-of-range panic in `Clip.Sublimate`
**Location:** `frame/frame.go:128-148` (`Sublimate`, indexing `clp.Guids[i]` at line 142 and `clp.Timestamps[i]` at line 143). `CheckLenCorrelations` exists at `frame/frame.go:151-166` but is **never called** on any receive path.
- Consumers that crash: `coordinator/main.go:172-180`, `mofin/main.go:100-108`, `terp/main.go:117-126,138`.
- `coordinator` additionally indexes `clp.Timestamps[0]` / `clp.Guids[0]` at `coordinator/main.go:193,217,327`.
**Description:** A `Clip` with a non-empty `PixelMats` but an empty/short `Guids` or `Timestamps` slice causes `Sublimate` to panic. These structs are decoded straight from the network via `gob` with zero validation.
**Impact:** One small crafted packet kills any ingest daemon (`coordinator`, `mofin`, `terp`). No authentication means any reachable host can take the entire recording pipeline down, repeatedly. This is a remote crash / availability vulnerability.
**Verified:** unit test reproduced `runtime error: index out of range [0] with length 0`.
**Recommendation:** Validate `Clip` length invariants (`CheckLenCorrelations`) immediately after decode on every receiver, bound the number of frames per clip, and recover from panics in connection handlers (`defer recover()`).
---
### 3. CRITICAL — Path traversal in `coordinator` → arbitrary file write outside storage
**Location:** `coordinator/main.go:191-196`:
```go
user, camera := splitSource(clp.SourceData) // line 191
...
clipDir := filepath.Join(storageDir, user, camera, dateShard, dirName)
if err := os.MkdirAll(clipDir, 0755); err != nil { ... }
```
`splitSource` (`coordinator/main.go:360-365`) does no sanitization; `SourceData` comes verbatim from the network.
**Description:** `filepath.Clean` preserves `..` elements that escape the root. A `SourceData` of e.g. `../../../../tmp/pwn--cam` resolves `clipDir` to something outside `storageDir` and the coordinator then `MkdirAll`s it and writes `clip.mp4` / `clip.json` inside.
**Impact:** Remote arbitrary file creation/overwrite anywhere the `coordinator` process can write. At minimum: fill the disk, clobber config/log files, plant files for operators to open, or stage payloads. Because the process runs as the service user (often `root` on Pis), this can become full host compromise.
**Verified:** `filepath.Join("storage", "../../../../tmp/pwn", "cam", ...)` resolves to `/home/tmp/pwn/cam/...`, comfortably outside storage.
**Recommendation:** Sanitize/whitelist `SourceData` (allow only `[a-zA-Z0-9_-/]`), strip `..` segments, and resolve+verify the final path is within `storageDir` (`EvalSymlinks` / prefix check) before any `MkdirAll`.
---
### 4. CRITICAL — TLS private key for the livestream HTTPS server is committed to git
**Location:** `livestream-cache/key.pem` and `livestream-cache/cert.pem` (tracked; present in *every* commit since added). Used at `livestream-cache/main.go:344` (`ListenAndServeTLS`).
**Description:** The `key.pem` is a live private key checked into the repository (mode `0644`).
**Impact:** Anyone with repo access (or the public history) can operate the server identity and MITM the live camera streams served over `:8443`; combined with finding #1, the HTTPS layer provides no actual security for this deployment. Self-signed, so browsers already show warnings.
**Recommendation:** Remove both files from the repo **and history** (`git filter-repo`), rotate the key immediately, generate certs at deploy time or store in a secrets manager, and `chmod 600` the key file.
---
### 5. CRITICAL — Real surveillance footage is committed to the git repository
**Location:** `coordinator/storage/oko/front/0/2026-06-30/*/clip.mp4` + `clip.json` (tracked). The JSON exposes `"source_data": "oko--front/0"`, timestamps, GUIDs, detections.
**Description:** Actual camera recordings of a camera labeled `front` are in the repo and history.
**Impact:** Permanent privacy/legal exposure of security-camera footage of people and property. Anyone who ever gets the repo gets the footage. This cannot be fixed by deleting the files — history must be rewritten.
**Recommendation:** Purge storage output from git history (`git filter-repo`), add `coordinator/storage/**` (and `logs/`) to `.gitignore`, and ensure no future clip data is committed.
---
### 6. HIGH — Unbounded resource consumption / DoS: no limits on decode size, connections, or goroutines
**Location:**
- Unbounded connection + goroutine per accept: `coordinator/main.go:98-113`, `mofin/main.go:52-73`, `terp/main.go:59-102`, `livestream-cache/main.go:269-279`.
- Unbounded `gob.Decode` with no stream/field size caps: `coordinator/main.go:172`, `mofin/main.go:67`, `terp/main.go:74`, `livestream-cache/main.go:288`.
- No read/write deadlines on any socket (slow-loris friendly).
- Per-clip processing goroutines with no bound on frames-per-clip: `terp/main.go:141-151` (one goroutine per frame × one per classifier), `mofin/main.go:79-88`.
- Livestream cache map grows per untrusted `SourceData` key until the TTL janitor runs (`livestream-cache/main.go:66-73,235-254`) — attacker can flood with millions of synthetic IDs in 30 s.
- `http.Server` has no `ReadHeaderTimeout`/`MaxHeaderBytes`/connection limits (`livestream-cache/main.go:332-336`).
**Impact:** Memory exhaustion (gob-decode bombs, large `PixelMats`, giant maps), CPU exhaustion (frame/classifier goroutines), and connection exhaustion from a handful of sockets. All remotely triggerable without auth.
**Recommendation:** Enforce max clip size / frame count / dimension limits, `io.LimitReader` on connections, per-connection deadlines, semaphore-bounded worker pools instead of unbounded goroutines, and sane `http.Server` timeouts/limits.
---
### 7. HIGH — Live camera streams (`/stream/...`) served over HTTPS with no authentication
**Location:** `livestream-cache/main.go:354-422` (`handleMJPEGStream`).
**Description:** Any unauthenticated client that can reach `:8443` can fetch live frames:
- `GET /stream/oko--front/0` → live MJPEG stream.
- Valid camera IDs are trivially enumerable (200 vs 404, `main.go:361-364`).
- No auth headers, cookies, or originating-IP restrictions.
- Because it's a plain `<img>`-loadable resource, any web page the victim visits can embed the stream (no CORS framing/canvas interplay blocks images), leaking the feed to third parties.
**Impact:** Anyone on the network can watch the cameras live — the very thing a home/office security system must prevent.
**Recommendation:** Require authentication (session cookie or per-camera token), serve on a Tailscale-only listener, hide whether cameras exist (uniform responses), and add `X-Frame-Options`/CSP framing controls.
---
### 8. HIGH — Encryption defaults off: clips are plaintext on the wire and at rest
**Location:**
- `runCam/main.go:39``-passphrase` defaults to `""` (no encryption).
- `mofin/main.go:33`, `terp/main.go:31`, `coordinator/main.go:43` — decrypt enabled only when passphrase set.
- All provided configs ship `passphrase: ""` or omit it (`config.yaml`, `cam-only.yaml:12-13`, `livestream-cache/config.yaml:5`, `run-stack.sh`).
- No TLS anywhere in the pipeline; transport is raw TCP + gob (`frame/frame.go:239-252`, `frame/network.go:24-70`).
**Description:** The AES-256-GCM layer exists but is opt-in and off in every shipped config. Frames/clips traverse `runCam→mofin→terp→coordinator` as plaintext and are stored unencrypted. Even when enabled, there is no protection against replay.
**Impact:** On-path sniffers can reconstruct surveillance footage; anyone with storage read access reads plaintext footage; clips can be modified in transit without detection (GCM only protects when the passphrase is actually configured end-to-end).
**Recommendation:** Require encryption to be on (fail-closed), propagate the passphrase via environment/secret store rather than CLI flags, or use mutually-authenticated TLS. Add per-clip anti-replay (e.g., monotonic nonce/counter, unique clip nonce).
---
### 9. HIGH — Live-feed poisoning via spoofed `SourceData` on TCP ingest
**Location:** `livestream-cache/main.go:299-321` (`handleTCPConnection`). `passphrase := cameraPassphrases[sourceData]`; with an empty passphrase the frame is accepted and stored under the spoofed key, overwriting a real camera's cached frames.
**Description:** The ingest server trusts `clip.SourceData` from the wire. Because there is no sender authentication (#1) and encryption is off (#8), an attacker connected to `:8085` can impersonate any configured camera and overwrite what viewers see on `/stream/...`.
**Impact:** Viewers are shown attacker-chosen images (denial of the real feed, social engineering, hiding the attacker's presence from the livestream while clips still may record differently).
**Recommendation:** Authenticate senders and validate `SourceData` against the configured camera list; reject unknown sources and never allow overwriting another camera's cache entry except from authenticated senders.
---
### 10. MEDIUM — Secrets in plaintext / visible in process listings
**Location:**
- Passphrases and DB password passed as CLI flags: `runCam/main.go:39`, `coordinator/main.go:43,47` (`-passphrase`, `-db-password`), `mofin/main.go:33`, `terp/main.go:31`. Visible to any local user via `ps`.
- MySQL DSN is built inline (`coordinator/main.go:56-57`) and defaults to `root` with an empty password (`coordinator/main.go:46-48`).
- Config files store passphrases in plaintext YAML (`config.example.yaml:11,15` ships `passphrase: "change-me"` — a universally-known key if left in place).
**Impact:** Local privilege escalation/privacy loss on the host; a "well-known" default key if example config is lifted wholesale; database exposed if `root`/empty password ever connects to a reachable endpoint.
**Recommendation:** Pass secrets via environment variables or a secrets file with `0600` perms; stop defaulting to `root`/empty; never ship a default key.
---
### 11. MEDIUM — Compiled binaries committed to the repo
**Location:** `livestream-cache/livestream-cache`, `terp/terp`, `mofin/mofin` (tracked; `terp/terp` additionally shows uncommitted modifications in the working tree). The `.gitignore` lists them (`runCam/runCam`, etc.) but they were added anyway.
**Description:** Binaries cannot be audited or attributed; a modified, untracked `terp/terp` means the deployed binary may not match the source.
**Impact:** Supply-chain / integrity risk if such a repo is shared — someone could execute a trojaned binary with full pipeline privileges and never notice.
**Recommendation:** `git rm --cached` all compiled artifacts, keep `.gitignore` enforced (use `git check-ignore`/pre-commit), and always build from source (`run-stack.sh` / `oko-run` already build-if-missing).
---
### 12. MEDIUM — Live-stream endpoint is a CPU DoS by design
**Location:** `livestream-cache/main.go:377-420`.
**Description:** Every connected client triggers a full `frameToImage` + JPEG `encodeJPEG` every 100 ms regardless of whether the underlying frame changed (cache re-encode is per client, not per-frame). There is no client limit or rate limit.
**Impact:** A modest number of unauthenticated clients saturates CPU and starves the feed for everyone; no auth means trivially scriptable by an attacker.
**Recommendation:** Encode once per new frame and share the JPEG across clients (broadcast model), cap concurrent stream consumers globally and per camera, and add per-IP rate limits.
---
### 13. MEDIUM — No slow-loris / timeouts protection (all TCP services)
**Location:** accept handlers in `coordinator/main.go:98-113`, `mofin/main.go:52-73`, `terp/main.go:59-102`, `livestream-cache/main.go:269-279`; no `SetReadDeadline`/`SetWriteDeadline` anywhere; `http.Server` without `ReadHeaderTimeout` (`livestream-cache/main.go:332-336`).
**Impact:** Idle connections can be held open indefinitely by any local network peer, exhausting file descriptors and goroutines.
**Recommendation:** Set connection deadlines (e.g., 10-30 s idle timeout) and server timeouts; consider a small max-connections gate.
---
### 14. MEDIUM — World-readable storage and logs
**Location:**
- `coordinator`: `MkdirAll(..., 0755)` dirs and clip JSON written `0644` (`coordinator/main.go:196,316`).
- `livestream.log` (955 KB) committed to the repo, containing operational details of the livestream backend.
**Impact:** Any local account can read surveillance footage and logs; perms + repo history mean footage persists beyond the operator's control.
**Recommendation:** Use restrictive perms (e.g., `0700` dirs, `0600` files), and keep logs out of git (`.gitignore` `logs/`).
---
### 15. LOW — Camera enumeration & metadata leakage in responses
**Location:** `livestream-cache/main.go:361-364` (404 vs 200 reveals configured camera IDs); stream/client IPs logged (`main.go:380`).
**Impact:** Enumerating which cameras exist over an unauthenticated endpoint; minor privacy exposure of viewer IPs in logs.
**Recommendation:** Return a uniform response for unknown vs known cameras (with auth in place), and consider not logging client IPs (or logging to an authenticated-only log).
---
### 16. LOW — `mofin -threshold` is truncated through `uint8`
**Location:** `mofin/main.go:32,111` — flag is `int` (default 30) but `processClip` casts to `uint8`; shipped configs use `threshold: 5000` (`config.yaml`, `run-stack.sh:76`, `bots_readme.md`), which silently becomes `5000 & 0xFF = 136`.
**Impact:** Not a security break, but indicates config values are misinterpreted; behavior is not what operators think, which can cause unexpected motion-gating results.
**Recommendation:** Clamp or validate threshold to 0-255, or document it as percent-based math.
---
### 17. LOW — Replay of previously captured clips
**Location:** `frame/frame.go:172-237` (AES-GCM without an anti-replay counter).
**Description:** GCM protects integrity/confidentiality but the protocol has no nonce-uniqueness enforcement across clips or sequence numbers, so a captured encrypted clip can be replayed into storage/livestream.
**Impact/Recommendation:** Low for a home system; add a unique per-clip nonce/preimage (e.g., GUID-derived) and monotonic counter if replay resistance matters.
---
### 18. INFO — `genGuid` panics on entropy failure; `CfgData.PassHash` is a misnomer
**Location:** `frame/cameraFunctions.go:59-67` (`panic` on `crypto/rand` failure — turns a rare-but-recoverable condition into a crash), and `frame/cameraFunctions.go:12-15,37,55` (`PassHash` holds the raw passphrase bytes, not a hash; `SourceData` embeds it as `user--pass`).
**Impact/Recommendation:** Entropy panic is a crash-under-adversarial-CSPRNG issue. The `PassHash` naming invites future misuse (embedding real secrets in `SourceData`); rename and ensure the actual passphrase is never serialized into `SourceData` (the current `runCam` path no longer does this — keep it that way).
---
## Cross-cutting recommendations
1. **Zero-trust the wire:** authenticate every peer and message (token or client-cert TLS), set explicit bind interfaces, and make encryption non-optional (fail closed).
2. **Validate everything decoded from the network:** length invariants (`CheckLenCorrelations`), frame counts, dimensions, `SourceData` charset, and path containment.
3. **Cap resources:** connection limits, decode sizes, worker-pool bounds, `http.Server` timeouts.
4. **Purge the repo:** re-key TLS, remove footage from history (`git filter-repo`), drop compiled binaries and logs, harden `.gitignore` + pre-commit hooks (block `*key*`, `*.pem`, storage/log output).
5. **Secrets hygiene:** environment-based secrets, no default `root`/empty DB creds, no examples with real-looking keys.
## Repo hygiene summary (what's tracked in git that shouldn't be)
| Item | Risk |
|---|---|
| `livestream-cache/key.pem` (private key) | TLS compromise → MITM of live streams |
| `coordinator/storage/**/clip.mp4`, `clip.json` | Real surveillance footage in history |
| `livestream-cache/livestream.log` | Operational data |
| `livestream-cache/livestream-cache`, `terp/terp`, `mofin/mofin` | Unverifiable compiled binaries (one modified) |
+249
View File
@@ -0,0 +1,249 @@
# Bot Notes — oko project observations
Notes from a full read-through of the repo (every source file, configs, tests, git history) on 2026-08-23.
---
## 1. What this project is
**oko** is a self-hosted, multi-stage video surveillance pipeline written in Go (GPL-3.0 licensed). It captures footage from local V4L2 cameras (webcams), runs motion detection and object classification over it, stores clips with metadata, and serves a live MJPEG stream to browsers. It is designed to run as separate cooperating processes connected by raw TCP + `encoding/gob`, with optional AES-256-GCM encryption of all pixel data in transit/at rest. Deployment targets appear to include Raspberry Pi-class hardware ("Pi Zero W friendly" comment, 5 fps defaults).
The README is minimal (`# oko` / "The full oko stack"); the real documentation lives in this file (which absorbed `bots_readme.md` — see the appendix for the mofin-stage changelog), `terp/readme.md`, and `coordinator/readme.md`.
## 2. Architecture
```
clips (gob/TCP)
runCam ─────────────────▶ mofin ──── has motion ────▶ terp ──▶ coordinator ──▶ storage/ + MySQL
│ │ (clip.mp4 + clip.json)
│ └──── no motion ────────▶ coordinator (bypasses classification)
└── single frames @ N fps ──▶ livestream-cache ──HTTPS MJPEG──▶ browsers (:8443)
oko-run: orchestrator that builds missing binaries and launches nodes listed in config.yaml
```
### Module layout (each directory is its own Go module)
| Directory | Role | Key deps |
|---|---|---|
| `frame/` | Shared library: core types, crypto, networking, motion, detection, camera capture | gocv v0.43.0, x/crypto |
| `runCam/` | Camera daemon: capture, clip building, hotplug rescan, livestream send | frame, gocv |
| `mofin/` | Motion gate between runCam and terp | frame, gocv |
| `terp/` | Classifier ("interpreter"): Haar cascade detection per frame | frame, gocv |
| `coordinator/` | Storage sink: mp4+json files, optional MySQL index | frame, gocv, go-sql-driver/mysql |
| `livestream-cache/` | Live-view backend: TCP ingest → frame cache → HTTPS MJPEG out | frame, yaml (gocv only indirect!) |
| `oko-run/` | Pipeline supervisor driven by YAML node list | yaml only (no gocv — builds anywhere) |
All modules pin `go 1.26.3` and use `replace frame => ../frame`.
### Ports / addressing convention
- `:8083` mofin ingest
- `:8081` terp ingest
- `:8082` coordinator ingest
- `:8085` livestream-cache TCP ingest
- `:8443` livestream-cache HTTPS MJPEG (`/stream/<user>--<camera>/<idx>`, `/health`)
### Data conventions
- **SourceData**: `<username>--<cameraID>/<v4lIndex>`, e.g. `oko--front/0`. Coordinator splits on first `--`.
- **GUIDs**: 16 random bytes (crypto/rand) with RFC-4122 version-4 bits set manually (`frame/cameraFunctions.go`).
- **Timestamps**: `uint64` UnixNano.
- **Wire format**: one gob-encoded `Clip` per TCP connection for pipeline hops; livestream sender keeps one connection open and streams many single-frame Clips through a shared `gob.Decoder`.
- **Detection map keys** are classifier identifiers, currently file paths: e.g. `"classifiers/haarcascade_fullbody.xml"` or `"yolo++best.onnx"`.
## 3. Stage-by-stage details
### `frame` (shared library)
- `Frame`: raw pixel bytes + dims + gocv MatType + channels + GUID + SourceData + Timestamp + Detections + Errors.
- `Clip`: parallel slices (`PixelMats [][]byte`, `Guids [][]byte`, `Timestamps []uint64`) plus shared dims/type. `Sublimate()` converts Clip → []Frame views (shares underlying pixel slices).
- **Crypto** (`frame/frame.go`): AES-256-GCM with PBKDF2-SHA256 key derivation, 600k iterations, per-frame random 16-byte salt + 12-byte nonce prepended. Empty PixelBytes is a no-op. Note: 600k PBKDF2 iterations *per frame* is CPU-heavy — at ~50 captured fps × 10s clips that's ~500 KDF runs per clip on every encrypting hop (and mofin decrypts then re-encrypts everything).
- **Motion** (`frame/motion.go`): `AbsDiff` → grayscale if multi-channel → binary threshold → `CountNonZero`. Stored as `Comparison{Guid1, Guid2, PixelsChanged, Threshold}` per adjacent pair. `GetHighestMotion()` returns worst-pair percent of changed pixels.
- **Detection** (`frame/detect.go`):
- `Detect(classifierLocations)` — parallel Haar cascades via `gocv.NewCascadeClassifier`, certainty hardcoded 1.0.
- `DetectYolo(modelPath)` — ONNX inference via `gocv.ReadNet`, input 416², conf 0.25; decodes YOLO-style output rows. **Newest commit ("Prelimenary work on integrating YOLO") adds this but nothing calls it yet**, and its helpers `filterNMS`/`iou` exist while `filterNMS` is never invoked from `detect()` — NMS is effectively unwired.
- Hardcoded default `modelPath = "/home/nolan/Pictures/chickens/fin/chicken-detector/weights/best.onnx"` — reveals the project's origin (chicken-coop monitoring) and embeds an absolute user path.
- **Networking** (`frame/network.go`): host:port regex validation; `Sendoff`/`SendFrames` return the unusual `(formatErr, dialErr, encodeErr)` triple. `Clip.Send` (used everywhere in the pipeline) does *not* validate addresses. `FrameListener`/`ClipListener` accept loops have no shutdown path (goroutine leak if used).
- **Capture** (`frame/cameraFunctions.go`): two generations coexist:
- Legacy: `RunCamera` (recursive self-restart on read failure, capped at depth 10), `generateFrames`, `preframeWrapper`, `CfgData.PassHash` (which gets concatenated into SourceData — would leak a password hash into stored metadata; luckily unused by current binaries).
- Current: `Capture(ctx, v4lIndex, output)` — context-driven, drops frames via non-blocking channel send when downstream is slow.
- `frame/utils.go` similarly has old `ScanCams` and newer `Scan`/`isCaptureDevice` (used by runCam).
- `frame/broadcast.go`: fan-out with drop-on-full policy and drop logging every 1000.
### `runCam`
- Flags for mofin address, cam scan range, clip duration (10s), rescan interval, user/camera-id, passphrase, livestream backend + fps.
- Main loop = hotplug rebalance ticker: `frame.Scan` every 10s, starts/stops per-camera goroutines tracked by `map[int]context.CancelFunc`.
- Per camera: capture → `Broadcast` to (a) clip builder and (b) livestream sender.
- Clip builder buffers frames, flushes on ticker; encrypts per-frame when passphrase set (dropping frames that fail to encrypt); sends whole clip to mofin.
- Livestream sender (`livestream.go`): holds latest frame under mutex, emits single-frame Clips at FPS ticks, reconnect-with-drop-count logic, optional encryption.
### `mofin`
- Decrypts (if passphrase), `CountChangedPixels(30)`, re-encrypts, routes: no-motion (`GetHighestMotion()==0`) → coordinator directly, else terp. Retry send with exponential backoff (6 tries, cap 500ms), then drop.
- `-motion-percent` flag is deprecated (gating moved to terp). `-coordinator` empty ⇒ everything goes to terp.
- Unbounded goroutine-per-clip processing (`procWg.Add` per incoming clip, no semaphore) — a burst could spike memory.
- Success-send logging is literally a stub: `// ...success logging...` (mofin/main.go:177).
### `terp`
- Loads classifiers via glob (default `classifiers/*.xml`; ships `haarcascade_fullbody.xml`).
- Recomputes motion itself (`CountChangedPixels(30)`) even though mofin already did — duplicated work by design ("trust but verify"?).
- Motion-percent gate, then per-frame parallel Haar detection, then **drops clips entirely if no detections** (motion but nothing recognized ⇒ data discarded, not archived).
- Forwards survivors to coordinator via buffered channel + single sender goroutine.
### `coordinator`
- Writes `storage/<user>/<camera>/<YYYY-MM-DD>/<firstTs>_<firstGuidHex>/clip.mp4` + `clip.json`.
- VideoWriter codec fallback chain: avc1 → H264 → MJPG.
- `computeFPS` derives fps from first timestamp delta (clamped 0120, default 30).
- Optional MySQL (`clips` + `detections` tables auto-created; FK cascade; per-detection inserts). Degrades gracefully to file-only mode if DB unreachable.
- Sample committed output exists: 10 real clips from 2026-06-30, 320×240 BGR, ~51fps source, with genuine fullbody-person Haar hits visible in clip.json.
### `livestream-cache`
- YAML-configured (cameras/passphrases keyed by SourceData), TCP ingest :8085, cache of latest frame per camera with TTL janitor (30s default), HTTPS MJPEG server :8443 with TLS 1.2 min, generated gray "offline" placeholder JPEG at quality 60.
- Pure-Go pixel conversion (BGR→RGBA loop, no OpenCV needed at runtime despite importing gocv transitively).
- Contains a hand-rolled `flagString` parser duplicating stdlib `flag` behavior.
- Only takes `PixelMats[0]` of each incoming clip — fine for single-frame livestream clips.
### `oko-run`
- Reads `nodes:` list from YAML; if `<rootDir>/<binary>/<binary>` doesn't exist, runs `go build -o <binary> .` inside that module dir; launches each node once with flags marshaled from a `map[string]interface{}` (note: Go map iteration order ⇒ flag order nondeterministic, harmless here).
- No restart/supervision of crashed nodes (single-shot `cmd.Run()`); shutdown = signal → CommandContext kill.
## 4. Configuration files
- `config.yaml` — full 4-node pipeline (cam1 front_door, mofin1, terp1 motion-percent 5, coord1 storage).
- `cam-only.yaml` — just runCam pointed at livestream backend :8085 (camera-id "front").
- `config.example.yaml` — documented template for the livestram side (mentions Tailscale for transport).
- `livestream-cache/config.yaml` — live backend config (passphrase currently empty).
- Note: oko-run does not read `config.example.yaml`; that one belongs to livestream-cache which loads `config.yaml` in its own CWD — two different `config.yaml` semantics depending on CWD, mildly confusing.
## 5. Tests
~79 test functions total, table-driven style, mostly in `frame`:
| File | Count | Coverage |
|---|---|---|
| `frame/frame_test.go` | 19 | ToMat roundtrip, encrypt/decrypt roundtrips & failures, unique salt, Sublimate, CheckLenCorrelations, Clip.Send TCP roundtrip, zero-value fields |
| `frame/motion_test.go` | 14 | compareTo edge cases (identical/partial/multi-channel/mismatched dims), CountChangedPixels incl. reset behavior |
| `frame/utils_test.go` | 15 | ScanCams bounds/dedup/parallel-safety, Scan integration, isCaptureDevice; hardware probes skip with `-short` |
| `mofin/mofin_test.go` | 6 | routing matrix (motion→terp, none→coordinator), passphrase decrypt/re-encrypt integrity, invalid-address timeout |
| `terp/terp_test.go` | 7 | motion gate, no-detection drop, passphrase wrong-key, timeout |
| `coordinator/coordinator_test.go` | 11 | splitSource table, computeFPS edges, video writer/json writing |
| `runCam/runcam_test.go` | 7 | contains(), livestream sender lifecycle/sends/unreachable |
Documented invocation: `go test -short ./...` per module (skips V4L hardware probes).
## 6. Build health on THIS machine (updated 2026-08-23)
- Toolchain: go1.27.0 present on host; modules demand go ≥ 1.26.3 (fine).
- **Host Arch Linux now ships OpenCV 5.0** (`opencv5.pc`); gocv v0.43.0 (latest) requires OpenCV **4.12** and does not compile against OpenCV 5 (massive API drift: module namespaces, removed `TrackerGOTURN`/`readNetFromCaffe`/etc.) nor against older 4.x distro builds (Ubuntu 24.04's 4.6 lacks `dnn::DataLayout`, `ImagePaddingMode`, new `FaceDetectorYN` overload). Distro with matching version: **Fedora 44 ships OpenCV 4.13 + contrib headers**, which gocv compiles against cleanly.
- **Solution in place**: `oko-dev` distrobox (Fedora 44) with `opencv-devel`, `gcc-c++`, `golang`, `git`. Use the repo-root wrapper: `./dev.sh <command...>` (e.g. `./dev.sh go test -short ./...`). All seven modules build, vet, and pass `-short` tests inside the box.
- Binaries link against container libs — run them inside the box too (`./dev.sh ../oko-run/oko-run ...`).
## 7. Repo hygiene / issues worth flagging
1. **TLS private key committed**: `livestream-cache/key.pem` (+ `cert.pem`) are tracked in git. Self-signed, but keys don't belong in history — should be gitignored/regenerated and rotated if ever reused.
2. **Build artifacts committed**: compiled binaries `terp/terp` (~large ELF) and `livestream-cache/livestream-cache` (13 MB) are tracked. `.gitignore` lists them (plus runCam/mofin/coordinator/oko-run binaries) but was added after they were committed, so ignore rules don't untrack them. `git rm --cached` needed.
3. **955 KB runtime log committed**: `livestream-cache/livestream.log`.
4. **Real surveillance footage committed**: 10 clips (mp4+json) under `coordinator/storage/oko/front/...` — presumably intentional test fixtures, but they bloat the repo and contain identifiable imagery.
5. **Hardcoded absolute path** in `frame/detect.go` modelPath (`/home/nolan/Pictures/chickens/...`) and a hardcoded retry limit/TODOs about infinite camera-restart loops in legacy `RunCamera`.
6. **Dead/legacy code in frame**: `RunCamera`, `generateFrames`, `preframeWrapper`, `CfgData`, `ScanCams`, `Sendoff`, `SendFrames`, `FrameListener`, `ClipListener`, `filterNMS`, `DetectYolo` (unwired), `Frame.Errors` field (defined, never populated outside tests).
7. **API quirks**: `Sendoff`'s `(err, err, err)` triple return; inconsistent address validation (regex in Sendoff/SendFrames, none in Send); success-path logging stub in mofin.
8. **Performance notes**: PBKDF2-600k per frame encryption is expensive; motion recomputation happens twice (mofin + terp); coordinator computes fps from only the first frame-pair delta.
9. **Git remote** is a LAN Gitea instance (`http://gitea:3000/nolan/oko`), branch `main` clean and up to date.
10. Commit messages are informal/casual ("SOmething. I don't remember", "worthless", "I don't know, kid stole my laptop") — history is hard to mine for intent; the appendix below preserves the mofin-stage writeup that compensated for this.
## 7b. Fixes made this session (2026-08-23)
1. **`livestream-cache/main_test.go` repaired**: fixed corrupted import `" .. /frame"``"frame"` and added the missing `fakeClip(n)` helper so the package compiles.
2. **Live gob protocol bug found & fixed** (`runCam/livestream.go`): `Clip.SendConn` created a fresh `gob.Encoder` per frame; each new Encoder re-sends type definitions, which the cache's shared decoder rejects ("gob: duplicate type received"). The committed `livestream.log` shows **5685** such errors — production livestream only updated once per reconnect cycle. Fix: one persistent `*gob.Encoder` per connection in `StartLivestreamSender`. Test client updated to match (single encoder), test now passes and validates the multi-clip-per-connection pattern.
## 7c. Future work: cgo-free capture nodes (TODO before Pi deployment)
- runCam's own source never calls gocv — it links OpenCV only transitively via the `frame`
package, forcing every capture binary to carry libopencv ≥4.12 runtime deps. That rules
out trivial cross-compilation to Raspberry Pis (Pi OS ships OpenCV 4.6; containers/
distrobox are a poor fit for headless camera nodes, and impossible on ARMv6 Zero W).
- **Planned fix**: split wire/data types (`Frame`, `Clip`, gob transport, AES-GCM crypto)
out of `frame` into a cgo-free subpackage (e.g. `framewire`); leave all gocv/CV code in
`frame`. Then build capture binaries with `CGO_ENABLED=0 GOARM=6/7` or `GOARCH=arm64`
static single-binary deploys with zero runtime requirements.
- Mechanical refactor (~1h): update imports across runCam (+ tests), adjust `replace`
directives in all go.mod files, keep mofin/terp/coordinator on full `frame`.
## 8. Where things seem headed
- YOLO ONNX integration is mid-flight (`DetectYolo` + decode logic landed, wiring/NMS/class-name mapping still missing; Detection struct already has version/postfix fields seemingly designed for richer classifier identity).
- Livestream stack (runCam sender + livestream-cache + browser MJPEG over Tailscale) is the newest subsystem and looks near-complete.
- MySQL indexing in coordinator is complete but optional; the file hierarchy remains the source of truth.
---
## Appendix — Historical changelog: mofin motion-detection stage
*(Absorbed from `bots_readme.md`, written by the session that added mofin. Note it
predates livestream-cache and oko-run — "five modules" then, seven now.)*
### Pipeline before / after
```
Old: runCam ──TCP──▶ terp ──TCP──▶ coordinator
New: runCam ──TCP──▶ mofin ──── has motion ────▶ terp ──▶ coordinator
└──── no motion ─────▶ coordinator (bypasses classification)
```
`mofin` runs pixel-level motion detection on each clip. Clips with motion are forwarded
to `terp` for classification; clips without motion go straight to `coordinator` for storage.
### Files created
**`frame/motion.go`**
- `Comparison{Guid1, Guid2 []byte; PixelsChanged int; Threshold uint8}` — result of one
adjacent-frame comparison.
- `(*Frame).compareTo(next *Frame, threshold uint8) (int, error)` — ToMat both frames →
`AbsDiff` → grayscale if multi-channel → binary `Threshold``CountNonZero`.
- `(*Clip).CountChangedPixels(threshold uint8)` — iterates adjacent pairs, appends a
`Comparison` per pair, logs errors from lightweight Frame views (pre-existing
Comparisons are cleared first).
**`mofin/main.go`**
| Flag | Default | Purpose |
|---|---|---|
| `-listen` | `:8083` | TCP address to receive clips from runCam |
| `-terp` | `localhost:8081` | Forward address for clips with motion |
| `-coordinator` | `localhost:8082` | Forward address for clips without motion (empty ⇒ all to terp) |
| `-threshold` | `5000` | Minimum `PixelsChanged` to count as motion |
| `-passphrase` | `""` | Decrypt/re-encrypt passphrase |
Concurrency: goroutine per incoming clip tracked by `procWg`; shutdown closes listener,
drains decodes via `acceptWg`, closes `clipChan`, waits on `procWg`.
`processClip` flow: decrypt → `CountChangedPixels(30)` → gate on any
`Comparison.PixelsChanged >= threshold` → re-encrypt → forward to terp or coordinator.
**`mofin/go.mod`** — standard module + `replace frame => ../frame`.
### Files modified
- **`frame/frame.go`**: added `Frame.Errors []string` and `Clip.Comparisons []Comparison`;
removed a stale duplicate `Detect` declaration.
- **`runCam/main.go`**: renamed `-terp` flag to `-mofin` (`localhost:8083`);
`terpAddr``mofinAddr` throughout.
- **`config.yaml`**: inserted `mofin1` node between `cam1` and `terp1`;
`cam1` repointed at mofin.
### Test inventory at time of writing
`frame/frame_test.go` — 19 tests: ToMat roundtrip/non-nil/empty-bytes;
Encrypt/Decrypt roundtrip, wrong-passphrase, empty no-op, truncated input, unique salt
per call; Sublimate basic/empty/detections-carryover; CheckLenCorrelations
match/PixelMats-Guids mismatch/Timestamps mismatch/empty; Clip.Send TCP roundtrip +
invalid address; zero-value Frame/Clip fields.
`frame/motion_test.go` — 14 tests: compareTo identical/all-changed/partial/threshold
filtering/multi-channel/dimension-mismatch/channel-mismatch/identical-pixel-data;
CountChangedPixels basic/single-frame/empty/reset-behavior/threshold-parameter;
Comparison struct fields.
`frame/utils_test.go` — 16 tests: ScanCams bounds/dedup/parallel-safety, Scan
integration, isCaptureDevice, pure-logic helpers; camera-dependent cases skip under
`-short`.
Plus per-module suites (mofin routing matrix, terp gates, coordinator writers, runCam
lifecycle) — full current counts in §5. Invocation: `go test -short ./...` per module.
+176
View File
@@ -0,0 +1,176 @@
# Bots Readme — Motion Detection (mofin) Pipeline Addition
## Overview
Added a motion detection stage (`mofin`) between `runCam` and `terp` in the video surveillance pipeline.
### Old Pipeline
```
runCam ──TCP──▶ terp ──TCP──▶ coordinator
```
### New Pipeline
```
runCam ──TCP──▶ mofin ──TCP──▶ terp ──TCP──▶ coordinator
└──TCP──▶ coordinator (no-motion clips)
```
`mofin` runs pixel-level motion detection on each clip. Clips with motion are forwarded to `terp` for classification (Haar cascade object detection). Clips without motion are sent directly to `coordinator` for storage, bypassing classification.
---
## Files Created
### `frame/motion.go`
Contains motion detection types and methods in the `frame` package.
**`Comparison` struct:**
```go
type Comparison struct {
Guid1, Guid2 []byte
PixelsChanged int
Threshold uint8
}
```
Stores the result of comparing two adjacent frames in a clip — their GUIDs, how many pixels changed, and the pixel-value threshold used.
**`(*Frame).compareTo(next *Frame, threshold uint8) (int, error)`:**
- Converts both frames to `gocv.Mat` via `ToMat()`
- Computes `AbsDiff` between the two Mats
- Converts to grayscale if multi-channel (`CvtColor`)
- Applies `Threshold` (pixel values above `threshold` count as changed)
- Returns `CountNonZero` (number of changed pixels) or `(0, error)` on failure
**`(*Clip).CountChangedPixels(threshold uint8)`:**
- Iterates over adjacent frame pairs in `c.PixelMats`
- For each pair, creates lightweight `Frame` objects from the clip's metadata and calls `compareTo`
- Logs any error returned by `compareTo` (avoiding writing to ephemeral Frame copies)
- Appends a `Comparison` entry to `c.Comparisons`
### `mofin/main.go`
A new pipeline binary that:
| Flag | Default | Purpose |
|---|---|---|
| `-listen` | `:8083` | TCP address to receive clips from `runCam` |
| `-terp` | `localhost:8081` | Forward address for clips with motion |
| `-coordinator` | `localhost:8082` | Forward address for clips without motion |
| `-threshold` | `5000` | Minimum `PixelsChanged` to consider motion present |
| `-passphrase` | `""` | Decryption/re-encryption passphrase (mirrors `terp` pattern) |
**Concurrency:** Clips are processed in parallel — each incoming clip gets its own goroutine (tracked by `procWg`). On shutdown, the listener is closed, in-flight decode goroutines drain via `acceptWg`, `clipChan` is closed, and `procWg.Wait()` blocks until all processing finishes before exiting.
**`processClip` flow:**
1. If `passphrase` is set, decrypt each frame's `PixelBytes` in-place via `Sublimate` + `Decrypt`
2. Call `clip.CountChangedPixels(30)` — pixel-value sensitivity of 30
3. Check if any `Comparison.PixelsChanged >= threshold` flag
4. Re-encrypt if passphrase was set
5. Forward to `terp` address (motion) or `coordinator` address (no motion)
### `mofin/go.mod`
Standard module setup with `replace frame => ../frame` (same pattern as `runCam`, `terp`, `coordinator`).
---
## Files Modified
### `frame/frame.go`
- Added `Errors []string` field to `Frame` — stores descriptive pipeline errors for backend review
- Added `Comparisons []Comparison` field to `Clip` — stores pairwise motion comparison results
- Removed a stale duplicate `Detect` method declaration at end of file
### `runCam/main.go`
- Renamed `-terp` flag to `-mofin` (default `localhost:8083`)
- Updated all internal references from `terpAddr``mofinAddr` in `main()`, `rebalance()`, and `runCamera()`
### `config.yaml`
Added `mofin1` node between `cam1` and `terp1`:
```yaml
- name: "mofin1"
binary: "mofin"
flags:
listen: ":8083"
terp: "localhost:8081"
coordinator: "localhost:8082"
threshold: 5000
```
Also updated `cam1` to point `mofin: "localhost:8083"` instead of `terp: "localhost:8081"`.
---
## Tests
### `frame/frame_test.go` — 19 tests
| Test | What it covers |
|---|---|
| `TestToMat_Roundtrip` | `ToMat()` returns an identical Mat from Frame data |
| `TestToMat_NonNilResult` | `ToMat()` succeeds with valid 2×2 grayscale data |
| `TestToMat_EmptyBytes` | `ToMat()` errors on zero-size buffer |
| `TestEncryptDecrypt_Roundtrip` | Encrypt then decrypt with same passphrase restores original bytes |
| `TestEncryptDecrypt_WrongPassphrase` | Decrypt with wrong passphrase fails |
| `TestEncryptDecrypt_EmptyBytes` | Encrypt/Decrypt on nil `PixelBytes` is a no-op |
| `TestDecrypt_TooShort` | Decrypt on truncated data fails |
| `TestEncrypt_UniqueSaltPerCall` | Two encryptions of same data produce different ciphertexts |
| `TestSublimate_Basic` | `Clip.Sublimate()` produces correct Frame count, metadata, GUIDs, timestamps |
| `TestSublimate_EmptyClip` | Empty clip produces zero frames |
| `TestSublimate_DetectionsCarryOver` | Frame detections are correctly mapped from Clip |
| `TestCheckLenCorrelations_Match` | Equal-length slices return true |
| `TestCheckLenCorrelations_PixelMatsGuidsMismatch` | Mismatched PixelMats/Guids returns false |
| `TestCheckLenCorrelations_TimestampsMismatch` | Mismatched Timestamps returns false |
| `TestCheckLenCorrelations_Empty` | Empty clip returns true |
| `TestClipSend` | Send/Receive roundtrip over TCP preserves Clip data |
| `TestClipSend_InvalidAddress` | Send to unreachable address returns error |
| `TestFrameFields_ZeroValues` | Zero-value Frame has nil Errors, nil Detections |
| `TestClipFields_ZeroValues` | Zero-value Clip has nil Comparisons |
### `frame/motion_test.go` — 12 tests
| Test | What it covers |
|---|---|
| `TestCompareTo_IdenticalFrames` | Two identical frames → 0 changed pixels |
| `TestCompareTo_AllPixelsChanged` | 0→255 → all 16 pixels of 4×4 detected as changed |
| `TestCompareTo_PartialChange` | 3 of 16 pixels differing → exactly 3 counted |
| `TestCompareTo_ThresholdFiltersSmallDiffs` | Pixels with diff > threshold counted; diff ≤ threshold ignored |
| `TestCompareTo_MultiChannel` | Multi-channel (BGR) → grayscale conversion works, changed pixels detected |
| `TestCompareTo_DimensionMismatch` | Different sizes return error |
| `TestCompareTo_ChannelMismatch` | Different channel counts return error |
| `TestCompareTo_IdenticalPixelData` | Same pixel bytes → 0 changed (no crash with equal data) |
| `TestCountChangedPixels_Basic` | 3 frames → 2 comparisons, correct pixel counts |
| `TestCountChangedPixels_SingleFrame` | 1 frame → 0 comparisons |
| `TestCountChangedPixels_EmptyClip` | 0 frames → 0 comparisons |
| `TestCountChangedPixels_ResetsComparisons` | Pre-existing Comparisons are cleared before run |
| `TestCountChangedPixels_ThresholdParameter` | Pixel-value threshold correctly filters small diffs |
| `TestComparison_Fields` | Comparison struct fields store/retrieve correctly |
### `frame/utils_test.go` — 16 tests
Camera-dependent tests skip with `-short` flag. Pure-logic tests (empty range, negative index, struct fields, Sscanf) always run.
### Running
```bash
go test -short ./... # fast (skips camera hardware scans)
go test ./... # full suite (attempts V4L device probes)
```
All 42+ tests pass. Pre-existing `ScanCams` tests preserved and corrected.
## Build Verification
All five modules pass `go build ./...` and `go vet ./...`:
- `frame/`
- `runCam/`
- `mofin/`
- `terp/`
- `coordinator/`
+11
View File
@@ -0,0 +1,11 @@
nodes:
- name: "cam1"
binary: "runCam"
flags:
mofin: "localhost:8083"
min-cam: 0
max-cam: 10
clip-duration: "10s"
rescan-interval: "10s"
user: "oko"
camera-id: "front"
+57
View File
@@ -0,0 +1,57 @@
# Shared pipeline configuration for the oko clip system.
# Each node maps binary -> flags (the same flags as the binaries' -help).
# The pipeline routes: runCam -> mofin -> terp -> coordinator (Matrix storage).
# Nodes that listen must be started backend-first.
pipeline:
runCam:
binary: "runCam"
flags:
mofin: "localhost:8083"
min-cam: 0
max-cam: 10
clip-duration: "10s"
rescan-interval: "10s"
user: "oko"
camera-id: "front"
video-fps: 5 # processed clip FPS; 0 = device native rate
video-width: 0 # capture width in px (0 = device default)
video-height: 0 # capture height in px (0 = device default)
passphrase: "change-me" # empty disables clip encryption
auth-token: "change-me" # shared with every node (or OKO_AUTH_TOKEN)
pbkdf2-iters: 600000 # lower = less CPU on weak devices (e.g. 50000 on a Pi Zero W)
mofin:
binary: "mofin"
flags:
listen: ":8083"
terp: "localhost:8081"
threshold: 30 # pixel-value sensitivity 0-255 (drop no-motion clips)
passphrase: "change-me"
auth-token: "change-me"
pbkdf2-iters: 600000
terp:
binary: "terp"
flags:
listen: ":8081"
sendoff: "localhost:8082"
classifiers: "classifiers/*.xml"
motion-percent: 0 # 0 = forward all motion clips to coordinator
passphrase: "change-me"
auth-token: "change-me"
pbkdf2-iters: 600000
coordinator:
binary: "coordinator"
flags:
listen: ":8082"
matrix-homeserver: "https://matrix.example.org"
matrix-user: "@okobot:matrix.example.org"
matrix-token-file: "matrix.token" # or OKO_MATRIX_TOKEN
clips-room: "!yourclips:example.org"
detections-room: "!yourdetections:example.org"
view-room: "!yourview:example.org" # clips with a one-line caption only, no JSON
passphrase: "change-me"
auth-token: "change-me"
pbkdf2-iters: 600000
+37
View File
@@ -0,0 +1,37 @@
nodes:
- name: "cam1"
binary: "runCam"
flags:
mofin: "localhost:8083"
min-cam: 0
max-cam: 10
clip-duration: "10s"
rescan-interval: "10s"
user: "oko"
camera-id: "front"
- name: "mofin1"
binary: "mofin"
flags:
listen: ":8083"
terp: "localhost:8081"
threshold: 30
- name: "terp1"
binary: "terp"
flags:
listen: ":8081"
sendoff: "localhost:8082"
classifiers: "classifiers/*.xml"
motion-percent: 5
- name: "coord1"
binary: "coordinator"
flags:
listen: ":8082"
matrix-homeserver: "https://ayrc.online"
matrix-user: "@okobot:ayrc.online"
matrix-token-file: "matrix.token"
clips-room: "!0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg"
detections-room: "!XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ"
view-room: "!wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE"
BIN
View File
Binary file not shown.
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"math"
"testing"
)
func TestComputeFPS_Standard(t *testing.T) {
// 33ms apart ≈ 30 fps
fps := computeFPS([]uint64{0, 33_333_333, 66_666_666})
got := math.Round(fps)
if got != 30 {
t.Errorf("got %.0f fps, want 30", got)
}
}
func TestComputeFPS_SingleFrame(t *testing.T) {
fps := computeFPS([]uint64{1000})
if fps != 30.0 {
t.Errorf("single frame: got %.1f, want 30.0", fps)
}
}
func TestComputeFPS_Empty(t *testing.T) {
fps := computeFPS(nil)
if fps != 30.0 {
t.Errorf("empty: got %.1f, want 30.0", fps)
}
}
func TestComputeFPS_ZeroDelta(t *testing.T) {
fps := computeFPS([]uint64{100, 100})
if fps != 30.0 {
t.Errorf("zero delta: got %.1f, want 30.0", fps)
}
}
func TestComputeFPS_HighFPS(t *testing.T) {
// 5ms apart ≈ 200 fps, clamped to 30
fps := computeFPS([]uint64{0, 5_000_000})
if fps != 30.0 {
t.Errorf("extreme fps: got %.1f, want 30.0", fps)
}
}
func TestComputeFPS_16ms(t *testing.T) {
// ~16.67ms → ~60 fps
fps := computeFPS([]uint64{0, 16_666_667})
got := math.Round(fps)
if got != 60 {
t.Errorf("got %.0f fps, want 60", got)
}
}
func TestComputeFPS_40ms(t *testing.T) {
// 40ms → 25 fps
fps := computeFPS([]uint64{0, 40_000_000})
got := math.Round(fps)
if got != 25 {
t.Errorf("got %.0f fps, want 25", got)
}
}
+28
View File
@@ -0,0 +1,28 @@
module coordinator
go 1.26.3
require (
frame v0.0.0
gocv.io/x/gocv v0.43.0
maunium.net/go/mautrix v0.29.0
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/rs/zerolog v1.35.1 // indirect
github.com/tidwall/gjson v1.19.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
go.mau.fi/util v0.10.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
replace frame => ../frame
+43
View File
@@ -0,0 +1,43 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
go.mau.fi/util v0.10.0 h1:vH9IXZmfBKa96p47HxrVqEPkrj02zDJg3o4EF172+Lk=
go.mau.fi/util v0.10.0/go.mod h1:uZwpm9sK4wO2Qqy+t6QoVq29szMsRxWXp9/BkQLG4xk=
gocv.io/x/gocv v0.43.0 h1:PFNpRUcV8fgBRDbVHHN+4BDZjjPnVveo5N/+e15BTuA=
gocv.io/x/gocv v0.43.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY=
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.29.0 h1:OkcBJF1dvp+93EgahxMxOUZZOrGTYculI9IprvRIMOQ=
maunium.net/go/mautrix v0.29.0/go.mod h1:LynuVr8N9nWsE1N4WAE+vItRACDB1pt9M3gN4SIBpeY=
+332
View File
@@ -0,0 +1,332 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"math"
"net"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"frame"
"gocv.io/x/gocv"
"maunium.net/go/mautrix/id"
)
func main() {
var (
listenAddr string
passphrase string
authToken string
matrixHS string
matrixUser string
matrixTokenFile string
clipsRoom string
detectionsRoom string
viewRoom string
pbkdf2Iter int
)
flag.StringVar(&listenAddr, "listen", ":8082", "address to listen for clips from terp")
flag.StringVar(&passphrase, "passphrase", "", "decryption passphrase (empty = no decryption)")
flag.StringVar(&authToken, "auth-token", "", "shared pipeline auth token (or OKO_AUTH_TOKEN)")
flag.StringVar(&matrixHS, "matrix-homeserver", "", "Matrix homeserver URL (required)")
flag.StringVar(&matrixUser, "matrix-user", "", "Matrix user ID, e.g. @oko:example.org (required)")
flag.StringVar(&matrixTokenFile, "matrix-token-file", "", "file containing the Matrix access token (or OKO_MATRIX_TOKEN env)")
flag.StringVar(&clipsRoom, "clips-room", "", "room ID for all motion clips (required)")
flag.StringVar(&detectionsRoom, "detections-room", "", "room ID for clips with detections (required)")
flag.StringVar(&viewRoom, "view-room", "", "room ID for clips with minimal caption only, no JSON metadata (required)")
flag.IntVar(&pbkdf2Iter, "pbkdf2-iters", frame.DefaultPBKDF2Iter, "PBKDF2 iterations for clip encryption (1..1000000)")
flag.Parse()
if pbkdf2Iter < 1 || pbkdf2Iter > frame.MaxPBKDF2Iter {
log.Fatalf("pbkdf2-iters must be in [1, %d], got %d", frame.MaxPBKDF2Iter, pbkdf2Iter)
}
frame.SetDefaultPBKDF2Iter(pbkdf2Iter)
tok := authToken
if tok == "" {
tok = os.Getenv("OKO_AUTH_TOKEN")
}
if tok == "" {
log.Fatal("authentication required: set -auth-token or OKO_AUTH_TOKEN")
}
frame.SetAuthToken(tok)
token := os.Getenv("OKO_MATRIX_TOKEN")
if matrixTokenFile != "" {
data, err := os.ReadFile(matrixTokenFile)
if err != nil {
log.Fatalf("Read matrix token file: %v", err)
}
token = strings.TrimSpace(string(data))
}
if matrixHS == "" || matrixUser == "" || token == "" {
log.Fatal("matrix-homeserver, matrix-user, and a token (OKO_MATRIX_TOKEN or -matrix-token-file) are required")
}
ms, err := newMatrixStore(matrixHS, matrixUser, token, clipsRoom, detectionsRoom, viewRoom)
if err != nil {
log.Fatalf("Initialize Matrix backend: %v", err)
}
log.Printf("Matrix backend ready")
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
ln, err := net.Listen("tcp", listenAddr)
if err != nil {
log.Fatalf("Listen %s: %v", listenAddr, err)
}
log.Printf("Listening on %s", listenAddr)
var wg sync.WaitGroup
rejected := frame.NewMalformedAttempts()
// Cap concurrent connections so a flood (or slowloris-style connection
// that parks on the auth handshake) cannot exhaust goroutines/FDs.
connSem := make(chan struct{}, 16)
// Cap concurrent Matrix ingest so a flood of clips cannot spawn unbounded
// encode+upload work. Deliberately acquired only inside the authenticated
// callback, so an unauthenticated connection cannot occupy an ingest slot.
sem := make(chan struct{}, 8)
go func() {
<-ctx.Done()
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
break
}
log.Printf("Accept error: %v", err)
continue
}
select {
case connSem <- struct{}{}:
default:
log.Printf("Rejecting connection from %s: too many concurrent connections", conn.RemoteAddr())
conn.Close()
continue
}
wg.Add(1)
go func(c net.Conn) {
defer func() { <-connSem }()
defer wg.Done()
frame.HandleClipConn(c, rejected, func(clp frame.Clip) {
sem <- struct{}{}
defer func() { <-sem }()
handleClip(ctx, ms, passphrase, clp)
})
}(conn)
}
if !frame.WaitGroupTimeout(&wg, 5*time.Second) {
log.Printf("In-flight handlers still working; exiting anyway")
}
if n := rejected.Total(); n > 0 {
log.Printf("Rejected %d malformed clips from the network", n)
}
log.Println("Exiting")
}
func handleClip(parent context.Context, ms *matrixStore, passphrase string, clp frame.Clip) {
targets := routeTargets(&clp, ms.clipsRoom, ms.detectionsRoom)
if len(targets) == 0 {
log.Printf("Dropped clip with no motion (source %s)", clp.SourceData)
return
}
if passphrase != "" {
if err := clp.Decrypt(passphrase); err != nil {
log.Printf("Decrypt error: %v", err)
return
}
}
frames := clp.Sublimate()
if len(frames) == 0 {
return
}
// Timebox encode + upload + posts together; this is the only archival hop,
// so a transient homeserver blip should be retried rather than dropping
// the footage.
ctx, cancel := context.WithTimeout(parent, 5*time.Minute)
defer cancel()
fps := computeFPS(clp.Timestamps)
mp4, err := encodeMP4(frames, int(clp.Width), int(clp.Height), fps)
if err != nil {
log.Printf("Encode clip error: %v", err)
return
}
filename := fmt.Sprintf("%x-%d.mp4", clp.Guids[0], clp.Timestamps[0])
var mxc id.ContentURI
if err := withRetry(ctx, 3, func() error {
mxc, err = ms.uploadClip(ctx, filename, mp4)
return err
}); err != nil {
log.Printf("Upload clip media: %v", err)
return
}
failed := 0
for _, roomID := range targets {
var eventID id.EventID
if err := withRetry(ctx, 2, func() error {
eventID, err = ms.sendClipToRoom(ctx, roomID, clp, filename, mxc, len(mp4), fps)
return err
}); err != nil {
failed++
log.Printf("Post clip to %s: %v", roomID, err)
continue
}
log.Printf("Stored clip (%d frames, motion=%d%%, %.1f fps) in %s as %s",
len(frames), clp.GetHighestMotion(), fps, roomID, eventID)
}
var viewID id.EventID
if err := withRetry(ctx, 2, func() error {
viewID, err = ms.sendClipView(ctx, ms.viewRoom, clp, mxc, len(mp4), fps)
return err
}); err != nil {
failed++
log.Printf("Post clip to view room: %v", err)
} else {
log.Printf("Stored clip (view, minimal) in %s as %s", ms.viewRoom, viewID)
}
if failed > 0 {
log.Printf("Failed to post clip to %d of %d target rooms", failed, len(targets)+1)
}
}
// withRetry runs fn up to attempts times with capped exponential backoff,
// returning early when ctx is done.
func withRetry(ctx context.Context, attempts int, fn func() error) error {
if attempts < 1 {
attempts = 1
}
var lastErr error
for i := 1; i <= attempts; i++ {
if err := fn(); err != nil {
lastErr = err
delay := 500 * time.Millisecond * time.Duration(1<<(i-1))
if delay > 5*time.Second {
delay = 5 * time.Second
}
select {
case <-ctx.Done():
return lastErr
case <-time.After(delay):
}
continue
}
return nil
}
return lastErr
}
func computeFPS(timestamps []uint64) float64 {
if len(timestamps) < 2 {
return 30.0
}
// Average of the consecutive inter-frame deltas, so a single irregular
// pair of timestamps cannot skew the reported rate.
var sum uint64
deltas := 0
for i := 1; i < len(timestamps); i++ {
if timestamps[i] <= timestamps[i-1] {
continue
}
sum += timestamps[i] - timestamps[i-1]
deltas++
}
if deltas == 0 {
return 30.0
}
avg := float64(sum) / float64(deltas)
fps := 1e9 / avg
if fps <= 0 || fps > 120 {
return 30.0
}
return math.Round(fps*100) / 100
}
// encodeMP4 renders the clip's frames to an H.264 MP4 in a temporary file and
// returns the bytes. The temp file is removed before returning.
func encodeMP4(frames []frame.Frame, width, height int, fps float64) ([]byte, error) {
tmp, err := os.CreateTemp("", "oko-clip-*.mp4")
if err != nil {
return nil, err
}
path := tmp.Name()
tmp.Close()
defer os.Remove(path)
if err := writeVideo(path, frames, width, height, fps); err != nil {
return nil, err
}
return os.ReadFile(path)
}
func writeVideo(path string, frames []frame.Frame, width, height int, fps float64) error {
isColor := true
if len(frames) > 0 && frames[0].Channels == 1 {
isColor = false
}
var vw *gocv.VideoWriter
var err error
for _, codec := range []string{"avc1", "H264", "MJPG"} {
vw, err = gocv.VideoWriterFile(path, codec, fps, width, height, isColor)
if err == nil && vw.IsOpened() {
break
}
if vw != nil {
vw.Close()
}
vw = nil
}
if vw == nil {
return fmt.Errorf("failed to open video writer with any codec: %v", err)
}
defer vw.Close()
written := 0
for i := range frames {
mat, err := frames[i].ToMat()
if err != nil {
log.Printf("ToMat error frame %d: %v", i, err)
continue
}
if err := vw.Write(mat); err != nil {
mat.Close()
return fmt.Errorf("write frame %d: %w", i, err)
}
mat.Close()
written++
}
if written == 0 {
return fmt.Errorf("no frames could be encoded")
}
if written < len(frames) {
log.Printf("Encoded %d of %d frames (partial clip)", written, len(frames))
}
return nil
}
+201
View File
@@ -0,0 +1,201 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"frame"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// matrixStore posts clips into Matrix rooms as m.video events. The clips and
// detections rooms carry full JSON metadata in an m.text reply posted directly
// beneath each video event; the view room carries only a one-line human caption
// so clips can be browsed without scrolling through JSON.
type matrixStore struct {
client *mautrix.Client
clipsRoom id.RoomID
detectionsRoom id.RoomID
viewRoom id.RoomID
}
// newMatrixStore builds an authenticated Matrix client and verifies the
// access token against the homeserver so that misconfiguration fails fast
// at startup instead of on the first clip.
func newMatrixStore(homeserver, userID, token, clipsRoom, detectionsRoom, viewRoom string) (*matrixStore, error) {
cli, err := mautrix.NewClient(homeserver, id.UserID(userID), token)
if err != nil {
return nil, fmt.Errorf("matrix client: %w", err)
}
cli.StateStore = mautrix.NewMemoryStateStore()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := cli.Whoami(ctx); err != nil {
return nil, fmt.Errorf("matrix auth check: %w", err)
}
for _, room := range []string{clipsRoom, detectionsRoom, viewRoom} {
if !strings.HasPrefix(room, "!") {
return nil, fmt.Errorf("invalid room ID %q (must start with '!')", room)
}
}
return &matrixStore{
client: cli,
clipsRoom: id.RoomID(clipsRoom),
detectionsRoom: id.RoomID(detectionsRoom),
viewRoom: id.RoomID(viewRoom),
}, nil
}
// uploadClip uploads a clip's MP4 to the homeserver media repository once and
// returns the mxc:// URI so every target room can reference the same media.
func (ms *matrixStore) uploadClip(ctx context.Context, filename string, mp4 []byte) (id.ContentURI, error) {
resp, err := ms.client.UploadBytesWithName(ctx, mp4, "video/mp4", filename)
if err != nil {
return id.ContentURI{}, fmt.Errorf("upload clip media: %w", err)
}
return resp.ContentURI, nil
}
// sendClipToRoom posts a clip's already-uploaded media as an m.video event to
// roomID, then sends the clip's metadata as an m.text reply beneath it. It
// returns the video event ID.
func (ms *matrixStore) sendClipToRoom(ctx context.Context, roomID id.RoomID, clp frame.Clip, filename string, mxc id.ContentURI, mp4Len int, fps float64) (id.EventID, error) {
videoResp, err := ms.client.SendMessageEvent(ctx, roomID, event.EventMessage, videoContent(filename, mxc, mp4Len))
if err != nil {
return "", fmt.Errorf("send clip video event: %w", err)
}
meta, err := buildClipMetadata(&clp, fps)
if err != nil {
return "", fmt.Errorf("build metadata: %w", err)
}
if _, err := ms.client.SendMessageEvent(ctx, roomID, event.EventMessage, metadataContent(videoResp.EventID, meta)); err != nil {
return "", fmt.Errorf("send metadata reply: %w", err)
}
return videoResp.EventID, nil
}
// videoContent renders the m.video event content for an uploaded clip, using
// label as the human-visible body text (the file name, or a short caption).
func videoContent(label string, mxc id.ContentURI, mp4Len int) *event.MessageEventContent {
return &event.MessageEventContent{
MsgType: event.MsgVideo,
Body: label,
URL: mxc.CUString(),
Info: &event.FileInfo{
MimeType: "video/mp4",
Size: mp4Len,
},
}
}
// sendClipView posts a clip's already-uploaded media to roomID as an m.video
// event with a one-line human caption instead of the JSON metadata reply, so
// the room reads as a clean clip feed.
func (ms *matrixStore) sendClipView(ctx context.Context, roomID id.RoomID, clp frame.Clip, mxc id.ContentURI, mp4Len int, fps float64) (id.EventID, error) {
resp, err := ms.client.SendMessageEvent(ctx, roomID, event.EventMessage, videoContent(viewCaption(&clp, fps), mxc, mp4Len))
if err != nil {
return "", fmt.Errorf("send clip view event: %w", err)
}
return resp.EventID, nil
}
// viewCaption renders the minimal, human-readable caption used for the view
// room. It carries just enough to identify a clip without any JSON.
func viewCaption(clp *frame.Clip, fps float64) string {
return fmt.Sprintf("%s · %d frames · %.1f fps · motion %d%%",
clp.SourceData, len(clp.PixelMats), fps, clp.GetHighestMotion())
}
// metadataContent renders the m.text reply that carries a clip's metadata
// beneath its video event.
func metadataContent(eventID id.EventID, meta string) *event.MessageEventContent {
return &event.MessageEventContent{
MsgType: event.MsgText,
Body: meta,
RelatesTo: &event.RelatesTo{
InReplyTo: &event.InReplyTo{
EventID: eventID,
},
},
}
}
// routeTargets decides where a clip should be stored. Clips with no motion
// are dropped (empty result). Motion clips land in the clips room and, when
// they also carry classifier detections, additionally in the detections room.
func routeTargets(clp *frame.Clip, clipsRoom, detectionsRoom id.RoomID) []id.RoomID {
if clp.GetHighestMotion() == 0 {
return nil
}
targets := []id.RoomID{clipsRoom}
if hasDetections(clp) {
targets = append(targets, detectionsRoom)
}
return targets
}
// hasDetections reports whether any frame in the clip carries at least one
// classifier detection.
func hasDetections(clp *frame.Clip) bool {
for _, frameDets := range clp.Detections {
for _, dets := range frameDets {
if len(dets) > 0 {
return true
}
}
}
return false
}
type clipJSON struct {
SourceData string `json:"source_data"`
Width uint `json:"width"`
Height uint `json:"height"`
Channels int `json:"channels"`
Type int `json:"gocv_image_type"`
FrameCount int `json:"frame_count"`
FPS float64 `json:"fps"`
Timestamps []uint64 `json:"timestamps"`
Guids []string `json:"guids"`
Detections []map[string][]frame.Detection `json:"detections"`
MotionPercent int `json:"motion_percent"`
}
// buildClipMetadata renders the clip's stored metadata as the text body of
// the reply post that sits beneath the clip's video event.
func buildClipMetadata(clp *frame.Clip, fps float64) (string, error) {
guids := make([]string, len(clp.Guids))
for i, g := range clp.Guids {
guids[i] = fmt.Sprintf("%x", g)
}
meta := clipJSON{
SourceData: clp.SourceData,
Width: clp.Width,
Height: clp.Height,
Channels: clp.Channels,
Type: int(clp.Types),
FrameCount: len(clp.PixelMats),
FPS: fps,
Timestamps: clp.Timestamps,
Guids: guids,
Detections: clp.Detections,
MotionPercent: clp.GetHighestMotion(),
}
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return "", fmt.Errorf("marshal: %w", err)
}
return string(data), nil
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"encoding/json"
"strings"
"testing"
"frame"
"gocv.io/x/gocv"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
const testClipsRoom = id.RoomID("!clips:server")
const testDetsRoom = id.RoomID("!dets:server")
const testViewRoom = id.RoomID("!view:server")
// testClip builds a clip with the given motion percentage (via Comparisons)
// and optional detections. Width/Height of 10x10 makes PixelsChanged == the
// motion percentage directly.
func testClip(motionPercent int, detections bool) *frame.Clip {
clp := &frame.Clip{
Width: 10,
Height: 10,
}
if motionPercent > 0 {
clp.Comparisons = []frame.Comparison{{PixelsChanged: motionPercent, Threshold: 30}}
}
if detections {
clp.Detections = []map[string][]frame.Detection{
{"person": {{DetectionTitle: "person", DetectionCertainty: 0.95}}},
}
}
return clp
}
func sameRooms(got, want []id.RoomID) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
func TestRouteTargets(t *testing.T) {
tests := []struct {
name string
motion int
hasDet bool
want []id.RoomID
}{
{"no motion, no detections", 0, false, nil},
{"no motion, detections present", 0, true, nil},
{"motion, no detections", 5, false, []id.RoomID{testClipsRoom}},
{"motion with detections", 5, true, []id.RoomID{testClipsRoom, testDetsRoom}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := routeTargets(testClip(tt.motion, tt.hasDet), testClipsRoom, testDetsRoom)
if !sameRooms(got, tt.want) {
t.Errorf("routeTargets = %v, want %v", got, tt.want)
}
})
}
}
func TestHasDetections(t *testing.T) {
tests := []struct {
name string
dets []map[string][]frame.Detection
want bool
}{
{"nil", nil, false},
{"empty frame map", []map[string][]frame.Detection{{}}, false},
{"empty frame slices", []map[string][]frame.Detection{{"person": {}}}, false},
{"one detection", []map[string][]frame.Detection{{"person": {{DetectionTitle: "person"}}}}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clp := &frame.Clip{Detections: tt.dets}
if got := hasDetections(clp); got != tt.want {
t.Errorf("hasDetections = %v, want %v", got, tt.want)
}
})
}
}
func TestVideoContent(t *testing.T) {
c := videoContent("aabb-100.mp4", id.MustParseContentURI("mxc://server/mediaid"), 2048)
if c.MsgType != event.MsgVideo {
t.Errorf("MsgType = %v, want MsgVideo", c.MsgType)
}
if c.Body != "aabb-100.mp4" {
t.Errorf("Body = %v, want aabb-100.mp4", c.Body)
}
if c.URL != "mxc://server/mediaid" {
t.Errorf("URL = %v, want mxc://server/mediaid", c.URL)
}
if c.Info == nil || c.Info.MimeType != "video/mp4" {
t.Errorf("Info.MimeType = %v, want video/mp4", c.Info.MimeType)
}
if c.Info == nil || c.Info.Size != 2048 {
t.Errorf("Info.Size = %v, want 2048", c.Info.Size)
}
}
func TestMetadataContent(t *testing.T) {
meta := `{"source_data":"oko--front"}`
c := metadataContent(id.EventID("$videoevent"), meta)
if c.MsgType != event.MsgText {
t.Errorf("MsgType = %v, want MsgText", c.MsgType)
}
if c.Body != meta {
t.Errorf("Body = %v, want %v", c.Body, meta)
}
if c.RelatesTo == nil || c.RelatesTo.InReplyTo == nil {
t.Fatalf("RelatesTo.InReplyTo not set")
}
if got := c.RelatesTo.InReplyTo.EventID; got != "$videoevent" {
t.Errorf("InReplyTo.EventID = %v, want $videoevent", got)
}
}
func TestBuildClipMetadata(t *testing.T) {
clp := frame.Clip{
PixelMats: [][]byte{{1, 2, 3}},
Width: 2,
Height: 2,
Types: gocv.MatTypeCV8UC1,
Channels: 1,
Guids: [][]byte{{0xaa, 0xbb}},
Timestamps: []uint64{100},
SourceData: "oko--front_door",
Detections: []map[string][]frame.Detection{
{"person": {{DetectionTitle: "person", DetectionCertainty: 0.9}}},
},
Comparisons: []frame.Comparison{{PixelsChanged: 1, Threshold: 30}},
}
data, err := buildClipMetadata(&clp, 30.0)
if err != nil {
t.Fatalf("buildClipMetadata: %v", err)
}
var meta map[string]interface{}
if err := json.Unmarshal([]byte(data), &meta); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
if meta["source_data"] != "oko--front_door" {
t.Errorf("source_data = %v, want oko--front_door", meta["source_data"])
}
if meta["motion_percent"] != float64(25) {
t.Errorf("motion_percent = %v, want 25 (1 of 4 pixels)", meta["motion_percent"])
}
if meta["frame_count"] != float64(1) {
t.Errorf("frame_count = %v, want 1", meta["frame_count"])
}
if meta["fps"] != float64(30) {
t.Errorf("fps = %v, want 30", meta["fps"])
}
guids, ok := meta["guids"].([]interface{})
if !ok || len(guids) != 1 || guids[0] != "aabb" {
t.Errorf("guids = %v, want hex aabb", meta["guids"])
}
dets, ok := meta["detections"].([]interface{})
if !ok || len(dets) != 1 {
t.Errorf("detections not preserved in metadata")
}
}
func TestViewCaption(t *testing.T) {
clp := frame.Clip{
PixelMats: [][]byte{{1, 2, 3}, {4, 5, 6}},
Width: 10,
Height: 10,
SourceData: "oko--front_door",
Comparisons: []frame.Comparison{
{PixelsChanged: 10, Threshold: 30},
{PixelsChanged: 20, Threshold: 30},
},
}
// minimal, human-readable, and free of JSON
got := viewCaption(&clp, 15.5)
want := "oko--front_door · 2 frames · 15.5 fps · motion 20%"
if got != want {
t.Errorf("viewCaption = %q, want %q", got, want)
}
for _, bad := range []string{"{", "}", `"`, "motion_percent", "source_data"} {
if strings.Contains(got, bad) {
t.Errorf("viewCaption contains JSON-ish token %q: %q", bad, got)
}
}
}
+3
View File
@@ -0,0 +1,3 @@
# Coordinator
Backend process that keeps a directory structure where frames are saved as files. Directory structure is heirarchical with sotrage/user/camera/files and no metadata database.
Executable
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Run a command inside the oko-dev distrobox (Fedora 44, OpenCV 4.13, Go toolchain).
#
# Usage:
# ./dev.sh <command...>
# Examples:
# ./dev.sh go test -short ./... # run from any module dir
# ./dev.sh go vet ./...
# ./dev.sh ../oko-run/oko-run -config ../config.yaml
set -e
exec distrobox enter oko-dev -- "$@"
+130
View File
@@ -0,0 +1,130 @@
# oko — Matrix storage backend + dev homeserver (session log)
Date: 2026-09-05
Author: opencode session (big-pickle)
## Objective
Replace the oko coordinator's backend storage (previously MySQL + local filesystem)
with Matrix: every clip is stored in a Matrix room as an `m.video` event, with its
metadata posted as an `m.text` reply directly beneath the video event. A real
development homeserver (Conduit) was provisioned on the FreeBSD VPS `magbot.online`
so the system is usable end-to-end.
## Final routing rules (agreed behavior)
| criteria | outcome |
|-----------------------------------|------------------------------------------------|
| no motion | not stored (dropped before decode/encode) |
| motion, no detections | clips room only (+ view room, caption only) |
| motion + detections | clips room **and** detections room (+ view room, caption only) |
Every motion clip is also posted to a third **view room** (`-view-room`): the
same uploaded `mxc://` media as an `m.video` event whose body is a one-line
human caption (`<source> · <n> frames · <fps> fps · motion <n>%`) and no JSON
metadata reply, so a human can browse clips without scrolling through JSON.
Livestreaming is explicitly out of scope.
## Coordinator rewrite (Matrix backend)
- `coordinator/matrix.go` (new)
- `matrixStore` type wrapping `mautrix.Client` (maunium.net/go/mautrix v0.29.0).
- `newMatrixStore`: `Whoami` self-check to fail fast on bad token; both room IDs
must start with `!` (validated at startup).
- `postClip`: uploads a single encode (`UploadBytesWithName`) to get one `mxc://`
URL, then sends the `m.video` event and a metadata `m.text` reply with
`m.in_reply_to` pointing at the video event. Both rooms reference the same MXC.
- `routeTargets`: the routing truth table (no motion -> nil; motion -> clips room;
motion+detections -> clips room + detections room).
- `hasDetections`: true when any frame has non-empty detections.
- `clipJSON`/`buildClipMetadata`: metadata body shape (see schema below).
- `coordinator/main.go`
- New flags: `-listen`, `-passphrase`, `-matrix-homeserver`, `-matrix-user`,
`-matrix-token-file` (or `OKO_MATRIX_TOKEN` env), `-clips-room`, `-detections-room`,
`-view-room` (third room, minimal caption only).
- Removed `-storage`, `-db-*`, `-keepalive-url`-era MySQL/filesystem writes.
- Token is read from a file or env — never from argv (audit #10).
- No-motion clips dropped before decode/encode; retained `computeFPS`, temp-file
MP4 encode, TCP ingest, passphrase decrypt.
- Metadata `m.text` body (pretty-printed JSON), shown in the live rooms:
`source_data`, `width`, `height`, `channels`, `gocv_image_type`, `frame_count`,
`fps`, `timestamps`, `guids`, `detections` (per-frame map: title/version/…), plus
`motion_percent`.
## terp change
- `terp/main.go`: removed the "no classifier detections -> drop" branch; all motion
clips are now forwarded to the coordinator (required so motion-without-DaemonSet
clips reach Matrix).
- `terp/terp_test.go`: updated four tests for the new forwarding behavior.
- Note: `terp/terp` committed binary was rebuilt to match source (tracked, modified).
## Tests
- `coordinator/matrix_test.go` (new): `TestRouteTargets`, `TestHasDetections`,
`TestBuildClipMetadata`.
- `coordinator/coordinator_test.go`: rewritten; keeps `computeFPS` tests; removed
replaced filesystem/MySQL tests.
- All pass: `go build`, `go vet`, `go test ./...` in `coordinator` and `terp`.
## Config / repo wiring
- `config.yaml`: coord1 node now points at the real homeserver/rooms.
- `run-stack.sh`: env-overridable defaults `OKO_MATRIX_HS`, `OKO_MATRIX_USER`,
`OKO_MATRIX_TOKEN_FILE`, `OKO_CLIPS_ROOM`, `OKO_DETECTIONS_ROOM`.
- `.gitignore`: added `matrix.token`, `*.token`, `logs/`, `storage/`.
- `matrix.credentials.example`: redacted template (real creds live on the VPS and
in a local 0600 file — see below).
## Dev Matrix homeserver (Conduit) on magbot.online
- FreeBSD 14.2 VPS, 1 CPU / ~1 GB RAM / ~70 GB free; SSH as `nolan` (no passwordless
sudo, root SSH denied) -> everything installed user-space.
- `nolan`'s shell is fish: run remote logic via script files: `sh /path/script.sh`.
- Conduit 0.10.13 + rocksdb/gflags/snappy extracted to `/home/nolan/matrixroot`;
binary needs `LD_LIBRARY_PATH=/home/nolan/matrixroot/usr/local/lib`.
- Config: `/home/nolan/conduit/conduit.toml` (server_name magbot.online, port 8008,
rocksdb, max_request_size 100MB, registration now DISABLED, federation off).
- Runtime/DB/log: `/home/nolan/conduit/{data,conduit.log}`.
- Start script: `/home/nolan/start-conduit.sh` (sets LD_LIBRARY_PATH + CONDUIT_CONFIG,
nohup). `@reboot` autostart installed in `nolan` crontab.
- Accounts: bot `@okobot:magbot.online` (used by coordinator), viewer
`@nolan:magbot.online` (joined both rooms). Bot access token -> repo `matrix.token`
(0600, gitignored).
- Rooms: clips `!mSnSuG3WTmoTfN6lvNHjFsbwFoMeEXlk4xFRmbg824k`
(#oko-clips:magbot.online), detections `!bAkHwtJ5FhfXpEsUFesllGshMejkgKwSQeAPJ8muOq0`
(#oko-detections:magbot.online). Both private, nolan invited+joined.
- Real credentials stored (0600): VPS `/home/nolan/oko-matrix-credentials.txt`
and local `/tmp/opencode/oko-matrix-credentials.txt`.
- Reachable over plain HTTP on :8008; no TLS yet (later: nginx reverse proxy on
magbot.online, which already runs nginx).
## End-to-end verification (live)
- `curl` client API + register (token-based) worked before registration was locked;
unprivileged register now returns `M_FORBIDDEN`.
- mautrix path health-checked against the live server with a throwaway client
(`/tmp/opencode/matrixtest`): Whoami, media upload -> `mxc://`, `m.video` event,
metadata reply with `m.in_reply_to` all OK; viewer sync/timeline fetch confirmed.
- Ran the real `coordinator` binary against the live homeserver with synthetic
gob clips (`/tmp/opencode/clipsender`):
- motion only -> clip stored in clips room only.
- motion + person detection -> stored in clips room AND detections room.
- Viewer `@nolan` retrieved both events + metadata replies.
## Operational notes / TODOs
- Smoke/test clips remain in both rooms (Conduit client API returns
`M_UNRECOGNIZED` for redaction) — clean up via an admin tool when convenient.
- Conduit has no TLS; enable nginx reverse proxy (or tailscale) before deploying
to real cameras. Federation currently off.
- `terp/terp` tracked binary was back in sync with source (rebuilt; modified).
- Local `config.yaml` placeholders were replaced with live values; the livestream
side-channel (livestream-cache module + runCam livestream glue) and its config
schema were removed entirely; `config.example.yaml` now documents the clip
pipeline schema.
- No live `OKO_TEST_MATRIX`-gated e2e test was added to the Go test suite; the live
path was validated manually as described above.
- Coordinator not currently running as a service on the dev box — start via
`./run-stack.sh` (or manually) when cameras are attached.
+122
View File
@@ -0,0 +1,122 @@
package frame
import (
"context"
"crypto/rand"
"fmt"
"log"
"time"
"gocv.io/x/gocv"
)
// maxConsecutiveReadFailures is how many back-to-back failed V4L reads in a
// row are tolerated before Capture concludes the device is gone and signals
// the caller by closing the output channel.
const maxConsecutiveReadFailures = 30
func genGuid() ([]byte, error) {
id := make([]byte, 16)
if _, err := rand.Read(id); err != nil {
return nil, fmt.Errorf("genGuid: crypto/rand failed: %w", err)
}
id[6] = (id[6] & 0x0f) | 0x40
id[8] = (id[8] & 0x3f) | 0x80
return id, nil
}
// Capture opens a V4L camera and streams Frame values into output until ctx is
// cancelled. fps caps how many frames per second are processed (copied and
// forwarded), and requests the same rate from the driver when the driver
// honors it; 0 or negative means process at the camera's native rate. width and
// height, when > 0, request a specific capture resolution from the driver
// (0 = keep the device default). Extra frames are dropped without copying.
// If the device stops delivering frames for an extended period (maxConsecutiveReadFailures
// consecutive read failures), Capture logs the failure, closes output, and
// returns, so a dead camera surfaces to the caller instead of spinning
// forever. The caller is responsible for draining output after cancellation or
// failure, and must not close output itself.
func Capture(ctx context.Context, v4lIndex int, fps int, width, height int, output chan<- Frame) error {
cam, err := gocv.VideoCaptureDevice(v4lIndex)
if err != nil {
return fmt.Errorf("capture device %d: %w", v4lIndex, err)
}
if width > 0 {
cam.Set(gocv.VideoCaptureFrameWidth, float64(width))
}
if height > 0 {
cam.Set(gocv.VideoCaptureFrameHeight, float64(height))
}
// NOTE: VideoCaptureFPS is intentionally NOT set here. On V4L2 (and this
// UVC driver in particular) it is ignored for throttling, and setting it
// alongside a resolution change makes the device stream at 2-3x its native
// rate. The fps cap below (software) is what actually limits frame rate.
/* TODO: This code shouldn't have hardware-specific code for only one specific hardware
* While the Raspberry Pi Zero W is the default hardware target for the frontend, we should
* keep hardware agnostic flexibility in mind.
*/
minInterval := time.Duration(0)
if fps > 0 {
minInterval = time.Second / time.Duration(fps)
}
go func() {
defer cam.Close()
mat := gocv.NewMat()
defer mat.Close()
var lastSent time.Time
failures := 0
for {
select {
case <-ctx.Done():
return
default:
}
if !cam.Read(&mat) {
failures++
if failures >= maxConsecutiveReadFailures {
log.Printf("capture device %d: %d consecutive read failures, closing stream",
v4lIndex, failures)
close(output)
return
}
time.Sleep(50 * time.Millisecond)
continue
}
failures = 0
now := time.Now()
if minInterval > 0 && !lastSent.IsZero() && now.Sub(lastSent) < minInterval {
continue
}
guid, err := genGuid()
if err != nil {
log.Printf("capture device %d: %v", v4lIndex, err)
continue
}
f := Frame{
PixelBytes: mat.ToBytes(),
Width: uint(mat.Cols()),
Height: uint(mat.Rows()),
GocvImageType: mat.Type(),
Detections: make(map[string][]Detection),
Channels: mat.Channels(),
Guid: guid,
Timestamp: uint64(now.UnixNano()),
}
lastSent = now
select {
case output <- f:
default:
}
}
}()
return nil
}
+112
View File
@@ -0,0 +1,112 @@
package frame
import (
"fmt"
"log"
"path/filepath"
"sync"
"gocv.io/x/gocv"
)
// Classifier pairs a loaded OpenCV cascade with the label under which its
// detections are stored.
type Classifier struct {
Path string
Label string
cc gocv.CascadeClassifier
mu sync.Mutex // DetectMultiScale is not safe for concurrent use
}
// Close releases the underlying cascade. Callers must close every classifier
// built by LoadClassifiers once done with it.
func (c *Classifier) Close() {
c.cc.Close()
}
func (c *Classifier) label() string {
if c.Label != "" {
return c.Label
}
return c.Path
}
// LoadClassifiers builds a Classifier for every path matched by patterns. Each
// pattern is a glob; a pattern that matches nothing is an error, so a typo or
// missing model file fails at startup instead of silently disabling detection.
func LoadClassifiers(patterns []string) ([]Classifier, error) {
var classes []Classifier
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("bad classifier pattern %q: %w", pattern, err)
}
if len(matches) == 0 {
return nil, fmt.Errorf("classifier pattern %q matched no files", pattern)
}
for _, path := range matches {
cc := gocv.NewCascadeClassifier()
if !cc.Load(path) {
cc.Close()
return nil, fmt.Errorf("cannot load cascade %q", path)
}
classes = append(classes, Classifier{Path: path, Label: filepath.Base(path), cc: cc})
}
}
if len(classes) == 0 {
return nil, fmt.Errorf("no classifiers configured")
}
return classes, nil
}
// DetectClassifiers runs every classifier against the frame and stores
// detections under each classifier's label. The frame's Mat is converted once
// and shared; classifiers serialize on their own detection mutex.
func (f *Frame) DetectClassifiers(classifiers []Classifier) {
if len(classifiers) == 0 {
return
}
if f.Detections == nil {
f.Detections = make(map[string][]Detection)
}
mat, err := f.ToMat()
if err != nil {
log.Printf("DetectClassifiers: converting frame to Mat: %v", err)
return
}
defer mat.Close()
var mu sync.Mutex
var wg sync.WaitGroup
wg.Add(len(classifiers))
for i := range classifiers {
go func(c *Classifier) {
defer wg.Done()
c.mu.Lock()
rects := c.cc.DetectMultiScale(mat)
c.mu.Unlock()
if len(rects) == 0 {
return
}
label := c.label()
results := make([]Detection, 0, len(rects))
for _, r := range rects {
results = append(results, Detection{
DetectionTitle: label,
DetectionMajorVersion: 1,
DetectionMinorVersion: 0,
DetectionPostfix: "",
DetectionRegion: r,
DetectionCertainty: 1.0,
})
}
mu.Lock()
f.Detections[label] = results
mu.Unlock()
}(&classifiers[i])
}
wg.Wait()
}
+371
View File
@@ -0,0 +1,371 @@
package frame
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"image"
"io"
"sync"
"gocv.io/x/gocv"
"golang.org/x/crypto/pbkdf2"
)
const (
saltSize = 16
keySize = 32
nonceSize = 12
// MaxPBKDF2Iter bounds the iteration count a Clip may claim on the wire,
// so a malicious sender cannot force a receiver into an arbitrarily long
// key-derivation CPU burn. Kept generous above every default and
// configured value, but low enough that a rejected clip costs at most a
// few hundred milliseconds of KDF work.
MaxPBKDF2Iter = 1_000_000
// encryptedOverhead is how many bytes Frame.Encrypt prepends to a
// plaintext frame: 16 salt + 12 nonce + 16 GCM tag.
encryptedOverhead = saltSize + nonceSize + 16
// clipEncryptedOverhead is the per-frame overhead added by Clip.Encrypt:
// the 16-byte salt lives on the Clip, so each frame gains only the
// 12-byte nonce and 16-byte GCM tag.
clipEncryptedOverhead = nonceSize + 16
)
// DefaultPBKDF2Iter is the PBKDF2 iteration count used for new clip and frame
// encryption. Encrypt stores the count actually used on the Clip, so Decrypt
// honors whatever the origin matched regardless of a node's local setting
// (override per node with frame.SetDefaultPBKDF2Iter).
var DefaultPBKDF2Iter = 600_000
// SetDefaultPBKDF2Iter overrides the default PBKDF2 iteration count used to
// encrypt new clips and decrypt foreign clips that do not carry their own
// count.
func SetDefaultPBKDF2Iter(n int) {
if n < 1 {
n = 1
}
if n > MaxPBKDF2Iter {
n = MaxPBKDF2Iter
}
DefaultPBKDF2Iter = n
}
// isCV8U reports whether a MatType is an 8-bit unsigned depth, the only depth
// the pipeline understands: PixelBytes is counted as one byte per element, so
// a 16-bit or float type with a matching byte count would be decoded as a
// different size of matrix than the dimensions claim.
func isCV8U(t gocv.MatType) bool {
return int(t)&0x07 == 0
}
// matTypeChannels recovers the channel count encoded in a MatType, so a clip
// declaring Channels can be cross-checked against its declared type.
func matTypeChannels(t gocv.MatType) int {
return (int(t)>>3)&0x07 + 1
}
type DetectionCollection map[string][]Detection
type Detection struct {
DetectionTitle string // Car, human, human torso, bear, cat, animal, etc...
DetectionMajorVersion uint
DetectionMinorVersion uint
DetectionPostfix string // 2.3.4a4444248f
DetectionRegion image.Rectangle
DetectionCertainty float32
}
// Stores single frame data
type Frame struct {
PixelBytes []byte
Width, Height uint
GocvImageType gocv.MatType
Detections map[string][]Detection
Channels int
Guid []byte
SourceData string // sourcedata is <username>.<cameraID>
Timestamp uint64
}
func (f *Frame) ToMat() (gocv.Mat, error) {
if f.Width == 0 || f.Height == 0 || f.Channels < 1 || f.Channels > 4 {
return gocv.NewMat(), fmt.Errorf("frame: implausible dimensions %dx%d (%d channels)", f.Width, f.Height, f.Channels)
}
want := int(f.Width) * int(f.Height) * f.Channels
if len(f.PixelBytes) != want {
return gocv.NewMat(), fmt.Errorf("frame: %d pixel bytes, want %d for %dx%d x%d",
len(f.PixelBytes), want, f.Width, f.Height, f.Channels)
}
mat, err := gocv.NewMatFromBytes(int(f.Height), int(f.Width), f.GocvImageType, f.PixelBytes)
if err != nil {
return gocv.NewMat(), err
}
return mat, nil
}
// Series of frames in a sequence from the same camera.
// Most metadata should be identical
// Frames should be in order as stored, so Timestamps[5] should be taken directly from PIxelMats[5] and Guids[5]
type Clip struct {
PixelMats [][]byte // First order is different frames, second order is pixel bytes from frames
Width, Height uint
Types gocv.MatType
Detections []map[string][]Detection
Comparisons []Comparison
Channels int
Guids [][]byte
SourceData string
Timestamps []uint64
Salt []byte
PBKDF2Iter int // iteration count used by Encrypt, so a decrypting node can match it (0 = unknown)
}
func (clp *Clip) Sublimate() []Frame {
frames := make([]Frame, len(clp.PixelMats))
for i := range clp.PixelMats {
f := Frame{
PixelBytes: clp.PixelMats[i],
Width: clp.Width,
Height: clp.Height,
GocvImageType: clp.Types,
Detections: nil,
Channels: clp.Channels,
SourceData: clp.SourceData,
}
if i < len(clp.Detections) {
f.Detections = clp.Detections[i]
}
if i < len(clp.Guids) {
f.Guid = clp.Guids[i]
}
if i < len(clp.Timestamps) {
f.Timestamp = clp.Timestamps[i]
}
frames[i] = f
}
return frames
}
// PixelMats, Guids, and Timestamps should all be the same len.
func (clp *Clip) CheckLenCorrelations() (bool, error) {
l1 := len(clp.PixelMats)
l2 := len(clp.Guids)
l3 := len(clp.Timestamps)
switch {
case l1 != l2:
return false, fmt.Errorf("clip has %d pixel frames but %d guids", l1, l2)
case l2 != l3:
return false, fmt.Errorf("clip has %d guids but %d timestamps", l2, l3)
case l1 != l3:
return false, fmt.Errorf("clip has %d pixel frames but %d timestamps", l1, l3)
default:
return true, nil
}
}
func deriveKey(passphrase string, salt []byte, iterations int) []byte {
return pbkdf2.Key([]byte(passphrase), salt, iterations, keySize, sha256.New)
}
// deriverCache memoizes the last handful of PBKDF2 derivations keyed by
// (salt, iterations), so a flood of repeated clips (replays or forged copies
// that share a salt) cannot repeatedly re-run an expensive KDF to bog the
// node down. It is bounded and safe for concurrent use.
var deriverCache = struct {
sync.Mutex
entries map[string][]byte
}{}
func cachedDeriveKey(passphrase string, salt []byte, iterations int) []byte {
cacheKey := fmt.Sprintf("%x/%d", salt, iterations)
deriverCache.Lock()
got, ok := deriverCache.entries[cacheKey]
deriverCache.Unlock()
if ok {
return got
}
derived := deriveKey(passphrase, salt, iterations)
deriverCache.Lock()
if deriverCache.entries == nil {
deriverCache.entries = make(map[string][]byte)
}
if len(deriverCache.entries) >= 16 {
deriverCache.entries = make(map[string][]byte)
}
deriverCache.entries[cacheKey] = derived
deriverCache.Unlock()
return derived
}
// gcmEncrypt seals plaintext with fresh random nonce and returns
// nonce||ciphertext (ciphertext includes the GCM tag).
func gcmEncrypt(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return append(nonce, gcm.Seal(nil, nonce, plaintext, nil)...), nil
}
// gcmDecrypt opens data produced by gcmEncrypt with the same key.
func gcmDecrypt(data, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(data) < gcm.NonceSize() {
return nil, errors.New("encrypted data too short")
}
nonce := data[:gcm.NonceSize()]
return gcm.Open(nil, nonce, data[gcm.NonceSize():], nil)
}
// Encrypt encrypts PixelBytes using AES-256-GCM with a key derived from passphrase.
func (f *Frame) Encrypt(passphrase string) error {
if len(f.PixelBytes) == 0 {
return nil
}
salt := make([]byte, saltSize)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return err
}
key := deriveKey(passphrase, salt, DefaultPBKDF2Iter)
ciphertext, err := gcmEncrypt(f.PixelBytes, key)
if err != nil {
return err
}
f.PixelBytes = append(salt, ciphertext...)
return nil
}
// Decrypt decrypts PixelBytes using AES-256-GCM with a key derived from passphrase.
func (f *Frame) Decrypt(passphrase string) error {
return f.DecryptWithIter(passphrase, DefaultPBKDF2Iter)
}
// DecryptWithIter is Decrypt with an explicit iteration count, used by the
// legacy per-frame salted layout so a clip can honor the count its origin
// used.
func (f *Frame) DecryptWithIter(passphrase string, iterations int) error {
if len(f.PixelBytes) == 0 {
return nil
}
if len(f.PixelBytes) < saltSize+nonceSize+1 {
return errors.New("encrypted data too short")
}
salt := f.PixelBytes[:saltSize]
key := cachedDeriveKey(passphrase, salt, iterations)
plaintext, err := gcmDecrypt(f.PixelBytes[saltSize:], key)
if err != nil {
return err
}
f.PixelBytes = plaintext
return nil
}
// Encrypt seals every frame in the clip with a single PBKDF2 key derivation.
// The shared salt is stored on the clip; each frame gets its own random nonce.
// The iteration count used is recorded on the clip so any decrypting node can
// match it even when its local default differs. Frames are left untouched when
// their pixel buffer is empty.
func (c *Clip) Encrypt(passphrase string) error {
if len(c.PixelMats) == 0 {
return nil
}
c.PBKDF2Iter = DefaultPBKDF2Iter
salt := make([]byte, saltSize)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return err
}
key := deriveKey(passphrase, salt, c.PBKDF2Iter)
var errs []error
for i := range c.PixelMats {
if len(c.PixelMats[i]) == 0 {
continue
}
ct, err := gcmEncrypt(c.PixelMats[i], key)
if err != nil {
errs = append(errs, fmt.Errorf("frame %d: %w", i, err))
continue
}
c.PixelMats[i] = ct
}
c.Salt = salt
return errors.Join(errs...)
}
// Decrypt opens every frame previously sealed by Clip.Encrypt, deriving the
// key from the clip's stored salt. Clips without a clip-level salt
// (per-frame salted layout from before the single-KDF change) are handled via
// the per-frame fallback.
func (c *Clip) Decrypt(passphrase string) error {
if len(c.PixelMats) == 0 {
return nil
}
if len(c.Salt) == saltSize {
iterations := c.PBKDF2Iter
if iterations < 1 || iterations > MaxPBKDF2Iter {
iterations = DefaultPBKDF2Iter
}
key := cachedDeriveKey(passphrase, c.Salt, iterations)
var errs []error
for i := range c.PixelMats {
if len(c.PixelMats[i]) == 0 {
continue
}
plaintext, err := gcmDecrypt(c.PixelMats[i], key)
if err != nil {
errs = append(errs, fmt.Errorf("frame %d: %w", i, err))
continue
}
c.PixelMats[i] = plaintext
}
return errors.Join(errs...)
}
iterations := DefaultPBKDF2Iter
if c.PBKDF2Iter >= 1 && c.PBKDF2Iter <= MaxPBKDF2Iter {
iterations = c.PBKDF2Iter
}
var errs []error
for i := range c.PixelMats {
f := Frame{PixelBytes: c.PixelMats[i]}
if err := f.DecryptWithIter(passphrase, iterations); err != nil {
errs = append(errs, fmt.Errorf("frame %d: %w", i, err))
continue
}
c.PixelMats[i] = f.PixelBytes
}
return errors.Join(errs...)
}
+596
View File
@@ -0,0 +1,596 @@
package frame
import (
"bufio"
"bytes"
"fmt"
"image"
"net"
"strings"
"testing"
"time"
"gocv.io/x/gocv"
)
func ptr[T any](v T) *T { return &v }
func testFrame(t *testing.T, width, height int, matType gocv.MatType, fillVal float64) Frame {
t.Helper()
mat := gocv.NewMatWithSize(height, width, matType)
mat.SetTo(gocv.NewScalar(fillVal, fillVal, fillVal, 0))
return Frame{
PixelBytes: mat.ToBytes(),
Width: uint(mat.Cols()),
Height: uint(mat.Rows()),
GocvImageType: mat.Type(),
Channels: mat.Channels(),
Guid: []byte("test-guid"),
SourceData: "test--cam0",
Timestamp: uint64(time.Now().UnixNano()),
}
}
func TestToMat_Roundtrip(t *testing.T) {
mat := gocv.NewMatWithSize(4, 4, gocv.MatTypeCV8UC1)
mat.SetTo(gocv.NewScalar(128, 0, 0, 0))
original := mat.ToBytes()
f := Frame{
PixelBytes: original,
Width: 4,
Height: 4,
GocvImageType: gocv.MatTypeCV8UC1,
Channels: 1,
}
result, err := f.ToMat()
if err != nil {
t.Fatalf("ToMat() returned error: %v", err)
}
defer result.Close()
if result.Cols() != 4 || result.Rows() != 4 {
t.Errorf("got size %dx%d, want 4x4", result.Cols(), result.Rows())
}
got := result.ToBytes()
if !bytes.Equal(got, original) {
t.Errorf("pixel bytes differ after roundtrip")
}
}
func TestToMat_NonNilResult(t *testing.T) {
f := Frame{
PixelBytes: []byte{0, 1, 2, 3},
Width: 2,
Height: 2,
GocvImageType: gocv.MatTypeCV8UC1,
Channels: 1,
}
mat, err := f.ToMat()
if err != nil {
t.Fatalf("ToMat() error: %v", err)
}
defer mat.Close()
if mat.Empty() {
t.Error("expected non-empty Mat")
}
}
func TestToMat_EmptyBytes(t *testing.T) {
f := Frame{
PixelBytes: nil,
Width: 0,
Height: 0,
GocvImageType: gocv.MatTypeCV8UC1,
Channels: 1,
}
_, err := f.ToMat()
if err == nil {
t.Error("expected error for empty pixel data, got nil")
}
}
func TestEncryptDecrypt_Roundtrip(t *testing.T) {
original := []byte("these are my pixel bytes, there are many like them but these are mine")
f := Frame{
PixelBytes: original,
Width: 8,
Height: 2,
GocvImageType: gocv.MatTypeCV8UC3,
Channels: 3,
}
if err := f.Encrypt("secret"); err != nil {
t.Fatalf("Encrypt() error: %v", err)
}
if bytes.Equal(f.PixelBytes, original) {
t.Error("PixelBytes unchanged after encryption")
}
if err := f.Decrypt("secret"); err != nil {
t.Fatalf("Decrypt() error: %v", err)
}
if !bytes.Equal(f.PixelBytes, original) {
t.Error("PixelBytes differ after decrypt roundtrip")
}
}
func TestEncryptDecrypt_WrongPassphrase(t *testing.T) {
original := []byte("sensitive pixel data")
f := Frame{
PixelBytes: original,
Width: 4,
Height: 1,
GocvImageType: gocv.MatTypeCV8UC3,
Channels: 3,
}
if err := f.Encrypt("correct-horse-battery-staple"); err != nil {
t.Fatalf("Encrypt() error: %v", err)
}
if err := f.Decrypt("wrong-passphrase"); err == nil {
t.Error("expected error for wrong passphrase, got nil")
}
}
func TestEncryptDecrypt_EmptyBytes(t *testing.T) {
f := Frame{PixelBytes: nil}
if err := f.Encrypt("pass"); err != nil {
t.Errorf("Encrypt on empty bytes should succeed: %v", err)
}
if err := f.Decrypt("pass"); err != nil {
t.Errorf("Decrypt on empty bytes should succeed: %v", err)
}
}
func TestDecrypt_TooShort(t *testing.T) {
f := Frame{PixelBytes: []byte{0, 1, 2}}
if err := f.Decrypt("pass"); err == nil {
t.Error("expected error for too-short data, got nil")
}
}
func TestEncrypt_UniqueSaltPerCall(t *testing.T) {
data := []byte("same data every time")
f1 := Frame{PixelBytes: bytes.Clone(data)}
f2 := Frame{PixelBytes: bytes.Clone(data)}
f1.Encrypt("pass")
f2.Encrypt("pass")
if bytes.Equal(f1.PixelBytes, f2.PixelBytes) {
t.Error("two encryptions of same data should differ (salt)")
}
}
func testClip() Clip {
return Clip{
PixelMats: [][]byte{{1, 2, 3}, {4, 5, 6}},
Width: 1,
Height: 1,
Types: gocv.MatTypeCV8UC3,
Channels: 3,
Guids: [][]byte{{0x01}, {0x02}},
Timestamps: []uint64{100, 200},
}
}
func TestClipEncryptDecrypt_Roundtrip(t *testing.T) {
clp := testClip()
if err := clp.Encrypt("secret"); err != nil {
t.Fatalf("Encrypt() error: %v", err)
}
if len(clp.Salt) != saltSize {
t.Fatalf("clip salt is %d bytes, want %d", len(clp.Salt), saltSize)
}
for i, px := range clp.PixelMats {
if len(px) != 3+clipEncryptedOverhead {
t.Fatalf("frame %d is %d bytes, want %d encrypted", i, len(px), 3+clipEncryptedOverhead)
}
}
if err := clp.Decrypt("secret"); err != nil {
t.Fatalf("Decrypt() error: %v", err)
}
want := testClip()
for i := range clp.PixelMats {
if !bytes.Equal(clp.PixelMats[i], want.PixelMats[i]) {
t.Errorf("frame %d differs after clip decrypt roundtrip", i)
}
}
}
func TestClipEncryptDecrypt_WrongPassphrase(t *testing.T) {
clp := testClip()
if err := clp.Encrypt("correct-horse"); err != nil {
t.Fatalf("Encrypt() error: %v", err)
}
if err := clp.Decrypt("wrong-passphrase"); err == nil {
t.Error("expected error for wrong passphrase, got nil")
}
}
func TestClipDecrypt_LegacyPerFrameFallback(t *testing.T) {
want := testClip()
legacy := testClip()
for i := range legacy.PixelMats {
f := Frame{PixelBytes: legacy.PixelMats[i]}
if err := f.Encrypt("secret"); err != nil {
t.Fatalf("frame %d Encrypt() error: %v", i, err)
}
legacy.PixelMats[i] = f.PixelBytes
}
if len(legacy.Salt) != 0 {
t.Fatal("legacy clip must not carry a clip-level salt")
}
if err := legacy.Decrypt("secret"); err != nil {
t.Fatalf("Decrypt() error: %v", err)
}
for i := range legacy.PixelMats {
if !bytes.Equal(legacy.PixelMats[i], want.PixelMats[i]) {
t.Errorf("frame %d differs after legacy per-frame decrypt", i)
}
}
}
func TestClipEncrypt_RecordsIterationCount(t *testing.T) {
orig := DefaultPBKDF2Iter
defer SetDefaultPBKDF2Iter(orig)
SetDefaultPBKDF2Iter(12345)
clp := testClip()
if err := clp.Encrypt("secret"); err != nil {
t.Fatalf("Encrypt() error: %v", err)
}
if clp.PBKDF2Iter != 12345 {
t.Fatalf("clip records PBKDF2Iter=%d, want 12345", clp.PBKDF2Iter)
}
}
func TestClipDecrypt_HonorsStoredIterationCount(t *testing.T) {
orig := DefaultPBKDF2Iter
defer SetDefaultPBKDF2Iter(orig)
SetDefaultPBKDF2Iter(12345)
want := testClip()
clp := testClip()
if err := clp.Encrypt("secret"); err != nil {
t.Fatalf("Encrypt() error: %v", err)
}
if clp.PBKDF2Iter != 12345 {
t.Fatalf("clip PBKDF2Iter=%d, want 12345", clp.PBKDF2Iter)
}
// Decrypting node is configured with a different default; it must still
// use the count recorded on the clip.
SetDefaultPBKDF2Iter(600000)
if err := clp.Decrypt("secret"); err != nil {
t.Fatalf("Decrypt with mismatched default: %v", err)
}
for i := range clp.PixelMats {
if !bytes.Equal(clp.PixelMats[i], want.PixelMats[i]) {
t.Errorf("frame %d differs after decrypt honoring stored iterations", i)
}
}
}
func TestClipDecrypt_TamperedIterationCountFails(t *testing.T) {
orig := DefaultPBKDF2Iter
defer SetDefaultPBKDF2Iter(orig)
clp := testClip()
if err := clp.Encrypt("secret"); err != nil {
t.Fatalf("Encrypt() error: %v", err)
}
// Changing the count invalidates the derived key.
clp.PBKDF2Iter = 600001
if err := clp.Decrypt("secret"); err == nil {
t.Error("expected decrypt error after tampering with PBKDF2Iter, got nil")
}
}
func TestClipEncrypt_Empty(t *testing.T) {
clp := Clip{}
if err := clp.Encrypt("secret"); err != nil {
t.Errorf("Encrypt on empty clip should succeed: %v", err)
}
if len(clp.Salt) != 0 {
t.Error("empty clip should not be assigned a salt")
}
}
func TestAuthHandshake_Match(t *testing.T) {
SetAuthToken("s3cret")
t.Cleanup(func() { SetAuthToken("") })
server, client := net.Pipe()
defer server.Close()
defer client.Close()
delivered := make(chan Clip, 1)
go HandleClipConn(server, NewMalformedAttempts(), func(clp Clip) {
delivered <- clp
})
if err := writeAuthToken(client); err != nil {
t.Fatalf("writeAuthToken: %v", err)
}
clp := validTestClip()
if _, err := writeWireClip(client, &clp); err != nil {
t.Fatalf("encode: %v", err)
}
select {
case <-delivered:
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for authenticated clip")
}
}
func TestAuthHandshake_Mismatch(t *testing.T) {
SetAuthToken("s3cret")
t.Cleanup(func() { SetAuthToken("") })
server, client := net.Pipe()
defer server.Close()
defer client.Close()
done := make(chan struct{})
go func() {
HandleClipConn(server, NewMalformedAttempts(), func(Clip) {
t.Error("callback must not run for a bad token")
})
close(done)
}()
if _, err := fmt.Fprintf(client, "wrong-token\n"); err != nil {
t.Fatalf("write: %v", err)
}
client.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("HandleClipConn did not return for bad token")
}
}
func TestAuthSend_EndToEnd(t *testing.T) {
SetAuthToken("s3cret")
t.Cleanup(func() { SetAuthToken("") })
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
received := make(chan Clip, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
HandleClipConn(conn, NewMalformedAttempts(), func(clp Clip) {
received <- clp
})
}()
clp := testClip()
if err := clp.Send(ln.Addr().String()); err != nil {
t.Fatalf("Send() error: %v", err)
}
select {
case got := <-received:
if !bytes.Equal(got.PixelMats[0], []byte{1, 2, 3}) {
t.Error("received wrong pixel data")
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for clip")
}
}
func TestSublimate_Basic(t *testing.T) {
clp := Clip{
PixelMats: [][]byte{
{1, 2, 3},
{4, 5, 6},
},
Width: 1,
Height: 1,
Types: gocv.MatTypeCV8UC3,
Channels: 3,
Guids: [][]byte{{0x01}, {0x02}},
Timestamps: []uint64{100, 200},
SourceData: "test--cam0",
}
frames := clp.Sublimate()
if len(frames) != 2 {
t.Fatalf("got %d frames, want 2", len(frames))
}
for i, f := range frames {
if f.Width != 1 || f.Height != 1 {
t.Errorf("frame %d: got %dx%d, want 1x1", i, f.Width, f.Height)
}
if f.Channels != 3 {
t.Errorf("frame %d: got %d channels, want 3", i, f.Channels)
}
if f.Guid[0] != byte(i+1) {
t.Errorf("frame %d: guid = %x, want %x", i, f.Guid[0], byte(i+1))
}
if f.Timestamp != uint64(100*(i+1)) {
t.Errorf("frame %d: timestamp = %d, want %d", i, f.Timestamp, uint64(100*(i+1)))
}
if f.SourceData != "test--cam0" {
t.Errorf("frame %d: sourceData = %q", i, f.SourceData)
}
}
}
func TestSublimate_EmptyClip(t *testing.T) {
clp := Clip{}
frames := clp.Sublimate()
if len(frames) != 0 {
t.Errorf("got %d frames, want 0", len(frames))
}
}
func TestSublimate_DetectionsCarryOver(t *testing.T) {
dets := []map[string][]Detection{
{"human": {{DetectionTitle: "human", DetectionRegion: image.Rect(0, 0, 10, 10)}}},
nil,
}
clp := Clip{
PixelMats: [][]byte{{0}, {0}},
Guids: [][]byte{{1}, {2}},
Timestamps: []uint64{0, 0},
Detections: dets,
}
frames := clp.Sublimate()
if len(frames[0].Detections["human"]) != 1 {
t.Error("detections not carried over to frame 0")
}
if frames[1].Detections != nil {
t.Error("expected nil detections for frame 1")
}
}
func TestCheckLenCorrelations_Match(t *testing.T) {
clp := Clip{
PixelMats: [][]byte{{0}, {0}, {0}},
Guids: [][]byte{{1}, {2}, {3}},
Timestamps: []uint64{10, 20, 30},
}
ok, err := clp.CheckLenCorrelations()
if !ok {
t.Errorf("expected match, got error: %v", err)
}
}
func TestCheckLenCorrelations_PixelMatsGuidsMismatch(t *testing.T) {
clp := Clip{
PixelMats: [][]byte{{0}, {0}},
Guids: [][]byte{{1}},
Timestamps: []uint64{10, 20},
}
ok, _ := clp.CheckLenCorrelations()
if ok {
t.Error("expected mismatch")
}
}
func TestCheckLenCorrelations_TimestampsMismatch(t *testing.T) {
clp := Clip{
PixelMats: [][]byte{{0}, {0}},
Guids: [][]byte{{1}, {2}},
Timestamps: []uint64{10},
}
ok, _ := clp.CheckLenCorrelations()
if ok {
t.Error("expected mismatch")
}
}
func TestCheckLenCorrelations_Empty(t *testing.T) {
clp := Clip{}
ok, err := clp.CheckLenCorrelations()
if !ok {
t.Errorf("empty clip should match: %v", err)
}
}
func TestClipSend(t *testing.T) {
SetAuthToken("test-token")
t.Cleanup(func() { SetAuthToken("") })
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
addr := ln.Addr().String()
received := make(chan Clip, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
br := bufio.NewReader(conn)
line, err := br.ReadString('\n')
if err != nil || strings.TrimSpace(line) != "test-token" {
return
}
clp, err := DecodeWireClip(br)
if err == nil {
received <- clp
}
}()
sent := Clip{
PixelMats: [][]byte{{1, 2, 3}},
Width: 1,
Height: 1,
Types: gocv.MatTypeCV8UC3,
Channels: 3,
Guids: [][]byte{{42}},
Timestamps: []uint64{99},
SourceData: "test--cam0",
}
if err := sent.Send(addr); err != nil {
t.Fatalf("Send() error: %v", err)
}
select {
case got := <-received:
if !bytes.Equal(got.PixelMats[0], []byte{1, 2, 3}) {
t.Error("received wrong pixel data")
}
if got.Guids[0][0] != 42 {
t.Error("received wrong guid")
}
if got.Timestamps[0] != 99 {
t.Error("received wrong timestamp")
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for clip")
}
}
func TestClipSend_InvalidAddress(t *testing.T) {
clp := Clip{}
err := clp.Send("256.0.0.1:1")
if err == nil {
t.Error("expected error for unreachable address, got nil")
}
}
func TestFrameFields_ZeroValues(t *testing.T) {
f := Frame{}
if f.Detections != nil {
t.Error("expected nil Detections for zero-value Frame")
}
}
func TestClipFields_ZeroValues(t *testing.T) {
c := Clip{}
if c.Comparisons != nil {
t.Error("expected nil Comparisons for zero-value Clip")
}
}
+8
View File
@@ -0,0 +1,8 @@
module frame
go 1.26.3
require (
gocv.io/x/gocv v0.43.0
golang.org/x/crypto v0.52.0
)
+4
View File
@@ -0,0 +1,4 @@
gocv.io/x/gocv v0.43.0 h1:PFNpRUcV8fgBRDbVHHN+4BDZjjPnVveo5N/+e15BTuA=
gocv.io/x/gocv v0.43.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
+109
View File
@@ -0,0 +1,109 @@
package frame
import (
"errors"
"fmt"
"log"
"gocv.io/x/gocv"
)
type Comparison struct {
Guid1, Guid2 []byte
PixelsChanged int
Threshold uint8
}
func (f *Frame) compareTo(next *Frame, threshold uint8) (int, error) {
if f.Width != next.Width || f.Height != next.Height || f.Channels != next.Channels {
return 0, fmt.Errorf("frame dimension mismatch: (%dx%dx%d) vs (%dx%dx%d)",
f.Width, f.Height, f.Channels, next.Width, next.Height, next.Channels)
}
matA, err := f.ToMat()
if err != nil {
return 0, fmt.Errorf("ToMat error: %w", err)
}
defer matA.Close()
matB, err := next.ToMat()
if err != nil {
return 0, fmt.Errorf("ToMat error: %w", err)
}
defer matB.Close()
diff := gocv.NewMat()
defer diff.Close()
gocv.AbsDiff(matA, matB, &diff)
gray := gocv.NewMat()
defer gray.Close()
if diff.Channels() > 1 {
gocv.CvtColor(diff, &gray, gocv.ColorBGRToGray)
} else {
diff.CopyTo(&gray)
}
thresh := gocv.NewMat()
defer thresh.Close()
gocv.Threshold(gray, &thresh, float32(threshold), 255, gocv.ThresholdBinary)
return gocv.CountNonZero(thresh), nil
}
// GetHighestMotion returns the highest percentage of changed pixels between
// any two consecutive frames in the clip. It assumes CountChangedPixels has
// already populated Comparisons (i.e. motion has already been determined).
// The result is an integer percentage in the range [0, 100]. Returns 0 if
// there are no comparisons or the frame dimensions are unknown.
func (c *Clip) GetHighestMotion() int {
totalPixels := int(c.Width) * int(c.Height)
if totalPixels <= 0 || len(c.Comparisons) == 0 {
return 0
}
highest := 0
for _, cmp := range c.Comparisons {
percent := int((int64(cmp.PixelsChanged) * 100) / int64(totalPixels))
if percent > highest {
highest = percent
}
}
return highest
}
func (c *Clip) CountChangedPixels(threshold uint8) error {
c.Comparisons = nil
var errs []error
for i := 0; i < len(c.PixelMats)-1; i++ {
a := Frame{
PixelBytes: c.PixelMats[i],
Width: c.Width,
Height: c.Height,
GocvImageType: c.Types,
Channels: c.Channels,
Guid: c.Guids[i],
}
b := Frame{
PixelBytes: c.PixelMats[i+1],
Width: c.Width,
Height: c.Height,
GocvImageType: c.Types,
Channels: c.Channels,
Guid: c.Guids[i+1],
}
changed, err := a.compareTo(&b, threshold)
if err != nil {
log.Printf("CountChangedPixels: frame pair %d/%d: %v", i, i+1, err)
errs = append(errs, fmt.Errorf("pair %d/%d: %w", i, i+1, err))
continue
}
c.Comparisons = append(c.Comparisons, Comparison{
Guid1: c.Guids[i],
Guid2: c.Guids[i+1],
PixelsChanged: changed,
Threshold: threshold,
})
}
return errors.Join(errs...)
}
+325
View File
@@ -0,0 +1,325 @@
package frame
import (
"testing"
"time"
"gocv.io/x/gocv"
)
func frameFromMat(t *testing.T, mat *gocv.Mat) Frame {
t.Helper()
return Frame{
PixelBytes: mat.ToBytes(),
Width: uint(mat.Cols()),
Height: uint(mat.Rows()),
GocvImageType: mat.Type(),
Channels: mat.Channels(),
Guid: []byte("guid"),
Timestamp: uint64(time.Now().UnixNano()),
}
}
func TestCompareTo_IdenticalFrames(t *testing.T) {
mat := gocv.NewMatWithSize(4, 4, gocv.MatTypeCV8UC1)
defer mat.Close()
mat.SetTo(gocv.NewScalar(100, 0, 0, 0))
a := frameFromMat(t, &mat)
b := frameFromMat(t, &mat)
changed, err := a.compareTo(&b, 30)
if err != nil {
t.Fatalf("compareTo error: %v", err)
}
if changed != 0 {
t.Errorf("identical frames: got %d changed pixels, want 0", changed)
}
}
func TestCompareTo_AllPixelsChanged(t *testing.T) {
matA := gocv.NewMatWithSize(4, 4, gocv.MatTypeCV8UC1)
defer matA.Close()
matA.SetTo(gocv.NewScalar(0, 0, 0, 0))
matB := gocv.NewMatWithSize(4, 4, gocv.MatTypeCV8UC1)
defer matB.Close()
matB.SetTo(gocv.NewScalar(255, 0, 0, 0))
a := frameFromMat(t, &matA)
b := frameFromMat(t, &matB)
changed, err := a.compareTo(&b, 30)
if err != nil {
t.Fatalf("compareTo error: %v", err)
}
if changed != 16 {
t.Errorf("all pixels changed: got %d, want 16", changed)
}
}
func TestCompareTo_PartialChange(t *testing.T) {
h := 4
w := 4
matA := gocv.NewMatWithSize(h, w, gocv.MatTypeCV8UC1)
defer matA.Close()
matA.SetTo(gocv.NewScalar(0, 0, 0, 0))
matB := gocv.NewMatWithSize(h, w, gocv.MatTypeCV8UC1)
defer matB.Close()
matB.SetTo(gocv.NewScalar(0, 0, 0, 0))
matB.SetUCharAt(0, 0, 200)
matB.SetUCharAt(0, 1, 200)
matB.SetUCharAt(1, 0, 200)
a := frameFromMat(t, &matA)
b := frameFromMat(t, &matB)
changed, err := a.compareTo(&b, 30)
if err != nil {
t.Fatalf("compareTo error: %v", err)
}
if changed != 3 {
t.Errorf("partial change: got %d, want 3", changed)
}
}
func TestCompareTo_ThresholdFiltersSmallDiffs(t *testing.T) {
h := 2
w := 2
matA := gocv.NewMatWithSize(h, w, gocv.MatTypeCV8UC1)
defer matA.Close()
matA.SetTo(gocv.NewScalar(100, 0, 0, 0))
matB := gocv.NewMatWithSize(h, w, gocv.MatTypeCV8UC1)
defer matB.Close()
matB.SetTo(gocv.NewScalar(100, 0, 0, 0))
matB.SetUCharAt(0, 0, 101)
matB.SetUCharAt(0, 1, 131)
a := frameFromMat(t, &matA)
b := frameFromMat(t, &matB)
changed, err := a.compareTo(&b, 30)
if err != nil {
t.Fatalf("compareTo error: %v", err)
}
if changed != 1 {
t.Errorf("threshold: got %d, want 1 (only pixel diff >30 should count)", changed)
}
}
func TestCompareTo_MultiChannel(t *testing.T) {
matA := gocv.NewMatWithSize(2, 2, gocv.MatTypeCV8UC3)
defer matA.Close()
matA.SetTo(gocv.NewScalar(0, 0, 0, 0))
matB := gocv.NewMatWithSize(2, 2, gocv.MatTypeCV8UC3)
defer matB.Close()
matB.SetTo(gocv.NewScalar(0, 0, 0, 0))
// Set all channels at (0,0) to 255 so grayscale conversion keeps a high value
matB.SetUCharAt(0, 0, 255)
matB.SetUCharAt(0, 1, 255)
matB.SetUCharAt(0, 2, 255)
a := frameFromMat(t, &matA)
b := frameFromMat(t, &matB)
changed, err := a.compareTo(&b, 30)
if err != nil {
t.Fatalf("compareTo error: %v", err)
}
if changed == 0 {
t.Error("multi-channel: expected >0 changed pixels")
}
}
func TestCompareTo_DimensionMismatch(t *testing.T) {
matA := gocv.NewMatWithSize(4, 4, gocv.MatTypeCV8UC1)
defer matA.Close()
matB := gocv.NewMatWithSize(8, 8, gocv.MatTypeCV8UC1)
defer matB.Close()
a := frameFromMat(t, &matA)
b := frameFromMat(t, &matB)
_, err := a.compareTo(&b, 30)
if err == nil {
t.Error("expected error for dimension mismatch, got nil")
}
}
func TestCompareTo_ChannelMismatch(t *testing.T) {
matA := gocv.NewMatWithSize(4, 4, gocv.MatTypeCV8UC1)
defer matA.Close()
matB := gocv.NewMatWithSize(4, 4, gocv.MatTypeCV8UC3)
defer matB.Close()
a := frameFromMat(t, &matA)
b := frameFromMat(t, &matB)
_, err := a.compareTo(&b, 30)
if err == nil {
t.Error("expected error for channel mismatch, got nil")
}
}
func TestCompareTo_IdenticalPixelData(t *testing.T) {
a := Frame{
PixelBytes: []byte{0, 0, 0, 0},
Width: 2,
Height: 2,
GocvImageType: gocv.MatTypeCV8UC1,
Channels: 1,
}
b := Frame{
PixelBytes: []byte{0, 0, 0, 0},
Width: 2,
Height: 2,
GocvImageType: gocv.MatTypeCV8UC1,
Channels: 1,
}
changed, err := a.compareTo(&b, 30)
if err != nil {
t.Fatalf("compareTo error: %v", err)
}
if changed != 0 {
t.Errorf("identical pixel data: got %d changed, want 0", changed)
}
}
func TestCountChangedPixels_Basic(t *testing.T) {
mat := gocv.NewMatWithSize(4, 4, gocv.MatTypeCV8UC1)
defer mat.Close()
mat.SetTo(gocv.NewScalar(0, 0, 0, 0))
bytes0 := mat.ToBytes()
mat.SetTo(gocv.NewScalar(255, 0, 0, 0))
bytes1 := mat.ToBytes()
mat.SetTo(gocv.NewScalar(0, 0, 0, 0))
bytes2 := mat.ToBytes()
clp := Clip{
PixelMats: [][]byte{bytes0, bytes1, bytes2},
Width: 4,
Height: 4,
Types: gocv.MatTypeCV8UC1,
Channels: 1,
Guids: [][]byte{{0}, {1}, {2}},
Timestamps: []uint64{0, 1, 2},
}
clp.CountChangedPixels(30)
if len(clp.Comparisons) != 2 {
t.Fatalf("got %d comparisons, want 2", len(clp.Comparisons))
}
if clp.Comparisons[0].PixelsChanged != 16 {
t.Errorf("pair 0->1: got %d changed, want 16", clp.Comparisons[0].PixelsChanged)
}
if clp.Comparisons[1].PixelsChanged != 16 {
t.Errorf("pair 1->2: got %d changed, want 16", clp.Comparisons[1].PixelsChanged)
}
}
func TestCountChangedPixels_SingleFrame(t *testing.T) {
clp := Clip{
PixelMats: [][]byte{{0, 0, 0, 0}},
Width: 2,
Height: 2,
Types: gocv.MatTypeCV8UC1,
Channels: 1,
Guids: [][]byte{{0}},
Timestamps: []uint64{0},
}
clp.CountChangedPixels(30)
if len(clp.Comparisons) != 0 {
t.Errorf("got %d comparisons, want 0", len(clp.Comparisons))
}
}
func TestCountChangedPixels_EmptyClip(t *testing.T) {
clp := Clip{}
clp.CountChangedPixels(30)
if len(clp.Comparisons) != 0 {
t.Errorf("got %d comparisons, want 0", len(clp.Comparisons))
}
}
func TestCountChangedPixels_ResetsComparisons(t *testing.T) {
clp := Clip{
PixelMats: [][]byte{{0}},
Width: 1,
Height: 1,
Channels: 1,
Guids: [][]byte{{0}},
Timestamps: []uint64{0},
Comparisons: []Comparison{{PixelsChanged: 999}},
}
clp.CountChangedPixels(30)
if len(clp.Comparisons) != 0 {
t.Errorf("expected comparisons to be reset, got %d", len(clp.Comparisons))
}
}
func TestCountChangedPixels_ThresholdParameter(t *testing.T) {
matA := gocv.NewMatWithSize(2, 2, gocv.MatTypeCV8UC1)
defer matA.Close()
matA.SetTo(gocv.NewScalar(100, 0, 0, 0))
matB := gocv.NewMatWithSize(2, 2, gocv.MatTypeCV8UC1)
defer matB.Close()
matB.SetTo(gocv.NewScalar(100, 0, 0, 0))
matB.SetUCharAt(0, 0, 101)
matB.SetUCharAt(0, 1, 161)
clp := Clip{
PixelMats: [][]byte{matA.ToBytes(), matB.ToBytes()},
Width: 2,
Height: 2,
Types: gocv.MatTypeCV8UC1,
Channels: 1,
Guids: [][]byte{{0}, {1}},
Timestamps: []uint64{0, 1},
}
clp.CountChangedPixels(60)
if clp.Comparisons[0].PixelsChanged != 1 {
t.Errorf("threshold 60: got %d, want 1 (only diff >60 counts)", clp.Comparisons[0].PixelsChanged)
}
}
func TestComparison_Fields(t *testing.T) {
cmp := Comparison{
Guid1: []byte("a"),
Guid2: []byte("b"),
PixelsChanged: 42,
Threshold: 30,
}
if string(cmp.Guid1) != "a" || string(cmp.Guid2) != "b" {
t.Error("Guid fields mismatch")
}
if cmp.PixelsChanged != 42 {
t.Errorf("PixelsChanged = %d, want 42", cmp.PixelsChanged)
}
if cmp.Threshold != 30 {
t.Errorf("Threshold = %d, want 30", cmp.Threshold)
}
}
+230
View File
@@ -0,0 +1,230 @@
package frame
import (
"bufio"
"bytes"
"crypto/hmac"
"encoding/binary"
"encoding/gob"
"errors"
"fmt"
"io"
"log"
"net"
"strconv"
"strings"
"sync"
"time"
)
// Network timeouts for a single TCP hop: the auth handshake and the clip
// transfer must both complete within their deadlines or the connection is
// dropped (fail-closed).
const (
authTimeout = 10 * time.Second
ClipReadTimeout = 30 * time.Second
ClipWriteTimeout = 30 * time.Second
)
// maxAuthLine bounds the length of the auth token line, so a peer that never
// sends a newline cannot grow a buffer forever (the read deadline still
// applies).
const maxAuthLine = 4096
// Wire framing for clips: every gob-encoded Clip is sent as a 4-byte
// big-endian length followed by that many payload bytes. The receiving side
// refuses any length above MaxWireBytes before allocating, so a single
// crafted message cannot force a multi-gigabyte allocation on the decode
// path.
const (
wireHeaderSize = 4
MaxClipWireSize = MaxClipBytes + (16 << 20) // 80 MiB: pixel data + guids/timestamps/detections + gob overhead
)
// authToken is the shared secret required on every hop. An empty token is
// never usable: receivers fail closed rather than accepting unauthenticated
// connections, and senders require a configured token to write the handshake.
var (
authMu sync.RWMutex
authToken string
)
// SetAuthToken configures the shared token required by every send and receive
// on this process. The token is trimmed of surrounding whitespace so a
// config line with a stray newline keeps matching the handshake.
func SetAuthToken(tok string) {
authMu.Lock()
defer authMu.Unlock()
authToken = strings.TrimSpace(tok)
}
func getAuthToken() string {
authMu.RLock()
defer authMu.RUnlock()
return authToken
}
func isValidHostPort(s string) bool {
host, port, err := net.SplitHostPort(s)
if err != nil {
return false
}
if host == "" {
return false
}
p, err := strconv.Atoi(port)
return err == nil && p >= 1 && p <= 65535
}
// writeAuthToken emits the auth line to conn. It returns an error when no
// token is configured so a misconfigured sender never silently transmits an
// unauthenticated clip.
func writeAuthToken(conn net.Conn) error {
tok := getAuthToken()
if tok == "" {
return errors.New("auth: no token configured (fail-closed)")
}
if err := conn.SetWriteDeadline(time.Now().Add(authTimeout)); err != nil {
return err
}
_, err := fmt.Fprintf(conn, "%s\n", tok)
return err
}
// readAuthToken consumes the auth line from conn and returns a reader for the
// gob stream that follows. A missing, truncated, or mismatched token fails
// the connection (constant-time comparison), and an unconfigured token fails
// closed rather than accepting the peer silently.
func readAuthToken(conn net.Conn) (io.Reader, error) {
tok := getAuthToken()
if tok == "" {
return nil, errors.New("auth: no token configured on receiver (fail-closed)")
}
if err := conn.SetReadDeadline(time.Now().Add(authTimeout)); err != nil {
return nil, err
}
br := bufio.NewReader(conn)
line, err := readAuthLine(br)
if err != nil {
return nil, fmt.Errorf("auth handshake read: %w", err)
}
if !hmac.Equal([]byte(strings.TrimSpace(line)), []byte(tok)) {
return nil, errors.New("authenticate: token mismatch")
}
return io.MultiReader(br, conn), nil
}
// readAuthLine reads a single '\n'-terminated line, bounding how many bytes
// are consumed so a hostile peer cannot stream data without a newline.
func readAuthLine(br *bufio.Reader) (string, error) {
var sb strings.Builder
for {
b, err := br.ReadByte()
if err != nil {
return "", err
}
if b == '\n' {
return sb.String(), nil
}
if sb.Len() >= maxAuthLine {
return "", errors.New("auth handshake line exceeds limit")
}
sb.WriteByte(b)
}
}
// writeWireClip encodes clp as a length-prefixed gob stream to w. The length
// prefix lets the receiver bound allocation before decoding.
func writeWireClip(w io.Writer, clp *Clip) (int64, error) {
var buf bytes.Buffer
if err := gob.NewEncoder(&buf).Encode(clp); err != nil {
return 0, err
}
if buf.Len() > MaxClipWireSize {
return 0, fmt.Errorf("clip wire encoding of %d bytes exceeds limit of %d", buf.Len(), MaxClipWireSize)
}
var hdr [wireHeaderSize]byte
binary.BigEndian.PutUint32(hdr[:], uint32(buf.Len()))
if _, err := w.Write(hdr[:]); err != nil {
return 0, err
}
n, err := w.Write(buf.Bytes())
return int64(wireHeaderSize) + int64(n), err
}
// DecodeWireClip reads a length-prefixed Clip produced by writeWireClip. The
// declared length is validated against MaxClipWireSize before any allocation,
// so a hostile peer cannot force a huge single allocation. Validation of the
// Clip's structure is the caller's job (see Clip.Validate).
func DecodeWireClip(r io.Reader) (Clip, error) {
var hdr [wireHeaderSize]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return Clip{}, err
}
n := binary.BigEndian.Uint32(hdr[:])
if n > MaxClipWireSize {
return Clip{}, fmt.Errorf("clip wire length %d exceeds limit of %d", n, MaxClipWireSize)
}
payload := make([]byte, n)
if _, err := io.ReadFull(r, payload); err != nil {
return Clip{}, err
}
var clp Clip
if err := gob.NewDecoder(bytes.NewReader(payload)).Decode(&clp); err != nil {
return Clip{}, err
}
return clp, nil
}
// Send delivers the clip to target: auth handshake, then a length-prefixed
// gob encode within a write deadline.
func (clp *Clip) Send(target string) error {
if !isValidHostPort(target) {
return fmt.Errorf("invalid send address: %s", target)
}
conn, err := net.DialTimeout("tcp", target, 10*time.Second)
if err != nil {
return fmt.Errorf("dial %s: %w", target, err)
}
defer conn.Close()
if err := writeAuthToken(conn); err != nil {
return fmt.Errorf("auth write to %s: %w", target, err)
}
if err := conn.SetWriteDeadline(time.Now().Add(ClipWriteTimeout)); err != nil {
return err
}
if _, err := writeWireClip(conn, clp); err != nil {
return fmt.Errorf("encode to %s: %w", target, err)
}
return nil
}
// SendClipRetry attempts to deliver clp to target up to attempts times, with a
// backoff that doubles from 100ms up to a 500ms cap. The leaf services use it
// so a transient downstream outage (restart, network blip) does not silently
// lose footage.
func (clp *Clip) SendClipRetry(target string, attempts int) error {
if attempts < 1 {
attempts = 1
}
var lastErr error
for i := 1; i <= attempts; i++ {
if err := clp.Send(target); err != nil {
lastErr = err
delay := 100 * time.Millisecond * (1 << (i - 1))
if delay > 500*time.Millisecond {
delay = 500 * time.Millisecond
}
log.Printf("send to %s failed (attempt %d/%d): %v; retrying in %s",
target, i, attempts, err, delay)
time.Sleep(delay)
continue
}
return nil
}
return fmt.Errorf("send to %s failed after %d attempts: %w", target, attempts, lastErr)
}
+174
View File
@@ -0,0 +1,174 @@
package frame
import (
"errors"
"fmt"
"log"
"net"
"sync"
"time"
)
// ErrMalformedClip marks a Clip that decoded but failed structural
// validation: the sign of a buggy sender or an attacker, as opposed to a
// truncated or corrupt stream.
var ErrMalformedClip = errors.New("malformed clip")
// Limits enforced when receiving a clip, so a single untrusted Clip cannot
// exhaust memory or fan out unbounded work downstream.
const (
MaxClipFrames = 6000 // generous headroom over any realistic clip length
MaxClipDim = 8192 // pixels per edge; bounds the frame buffer size
MaxClipBytes = 64 << 20 // 64 MiB of pixel data (encrypted frames include their overhead)
)
// Validate checks a decoded Clip against its structural invariants so that
// downstream use (Sublimate, ToMat, encoding) can never panic or allocate
// wildly. Empty clips are considered valid and are simply ignored by
// consumers. Each frame's pixel buffer must match the declared dimensions
// (plaintext), or carry the fixed encryption overhead on top of that, so a
// sender cannot advertise one size but stream another.
func (c *Clip) Validate() error {
if len(c.PixelMats) == 0 {
return nil
}
if ok, err := c.CheckLenCorrelations(); !ok {
return fmt.Errorf("%w: %v", ErrMalformedClip, err)
}
if n := len(c.PixelMats); n > MaxClipFrames {
return fmt.Errorf("%w: %d frames exceeds the limit of %d", ErrMalformedClip, n, MaxClipFrames)
}
if c.Width == 0 || c.Height == 0 || c.Height > MaxClipDim || c.Width > MaxClipDim {
return fmt.Errorf("%w: implausible frame dimensions %dx%d", ErrMalformedClip, c.Width, c.Height)
}
if c.Channels < 1 || c.Channels > 4 {
return fmt.Errorf("%w: implausible channel count %d", ErrMalformedClip, c.Channels)
}
if !isCV8U(c.Types) {
return fmt.Errorf("%w: unsupported MatType depth %d (only 8-bit unsigned is accepted)", ErrMalformedClip, c.Types)
}
if n := matTypeChannels(c.Types); n != c.Channels {
return fmt.Errorf("%w: MatType declares %d channels but Clip.Channels is %d", ErrMalformedClip, n, c.Channels)
}
if c.PBKDF2Iter < 0 || c.PBKDF2Iter > MaxPBKDF2Iter {
return fmt.Errorf("%w: implausible PBKDF2 iteration count %d", ErrMalformedClip, c.PBKDF2Iter)
}
expected := int(c.Width) * int(c.Height) * c.Channels
clipEncrypted := expected + clipEncryptedOverhead
perFrameEncrypted := expected + encryptedOverhead
total := 0
for i, px := range c.PixelMats {
switch len(px) {
case 0:
continue
case expected, clipEncrypted, perFrameEncrypted:
total += len(px)
default:
return fmt.Errorf("%w: frame %d is %d bytes, want %d (or %d clip-level / %d per-frame encrypted)",
ErrMalformedClip, i, len(px), expected, clipEncrypted, perFrameEncrypted)
}
}
if total > MaxClipBytes {
return fmt.Errorf("%w: clip pixel data totals %d bytes, exceeds limit of %d",
ErrMalformedClip, total, MaxClipBytes)
}
return nil
}
// malformedAttemptSources caps the number of distinct source addresses the
// malformed-clip tracker retains, so a peer cycling many addresses cannot
// grow the map without bound.
const malformedAttemptSources = 256
// MalformedAttempts counts rejected malformed clip attempts per source
// address, so repeated bad senders are visible in the logs.
type MalformedAttempts struct {
mu sync.Mutex
count map[string]int
total int
}
// NewMalformedAttempts returns an empty malformed-clip attempt tracker.
func NewMalformedAttempts() *MalformedAttempts {
return &MalformedAttempts{count: make(map[string]int)}
}
// Reject records one malformed clip attempt from src, logs it with the
// source and the running per-source count, and returns that count.
func (m *MalformedAttempts) Reject(src string) int {
m.mu.Lock()
defer m.mu.Unlock()
if _, known := m.count[src]; !known && len(m.count) >= malformedAttemptSources {
m.total++
log.Printf("malformed clip rejected from %s (tracked-sources cap reached)", src)
return 0
}
m.count[src]++
n := m.count[src]
m.total++
log.Printf("malformed clip rejected from %s (attempt #%d)", src, n)
return n
}
// Total returns the total number of rejected attempts across all sources.
func (m *MalformedAttempts) Total() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.total
}
// decodeClip reads and validates a single Clip from conn. The auth handshake
// runs first (writing nothing, fail-closed); corrupt or truncated streams
// yield a plain decode error; structurally invalid Clips yield an error
// wrapping ErrMalformedClip, counted and logged against rej when it is
// non-nil.
func decodeClip(conn net.Conn, rej *MalformedAttempts) (Clip, error) {
var clp Clip
reader, err := readAuthToken(conn)
if err != nil {
return clp, err
}
if err := conn.SetReadDeadline(time.Now().Add(ClipReadTimeout)); err != nil {
return clp, err
}
clp, err = DecodeWireClip(reader)
if err != nil {
return clp, err
}
if err := clp.Validate(); err != nil {
if rej != nil {
rej.Reject(remoteAddr(conn))
}
return clp, err
}
return clp, nil
}
// HandleClipConn runs the standard single-clip ingest flow for conn: closes
// it, decodes and validates exactly one Clip, and hands it to fn. Panics and
// malformed input are contained and logged, so a rogue connection can never
// crash the process.
func HandleClipConn(conn net.Conn, rej *MalformedAttempts, fn func(Clip)) {
defer conn.Close()
defer func() {
if r := recover(); r != nil {
log.Printf("panic while ingesting clip from %s: %v", remoteAddr(conn), r)
}
}()
clp, err := decodeClip(conn, rej)
if err != nil {
if !errors.Is(err, ErrMalformedClip) {
log.Printf("decode error from %s: %v", remoteAddr(conn), err)
}
return
}
fn(clp)
}
func remoteAddr(conn net.Conn) string {
if conn == nil {
return "<unknown>"
}
return conn.RemoteAddr().String()
}
+269
View File
@@ -0,0 +1,269 @@
package frame
import (
"errors"
"net"
"strings"
"testing"
"time"
"gocv.io/x/gocv"
)
func validTestClip() Clip {
return Clip{
PixelMats: [][]byte{{1, 2, 3}, {4, 5, 6}},
Guids: [][]byte{{1}, {2}},
Timestamps: []uint64{100, 200},
Width: 1,
Height: 1,
Types: gocv.MatTypeCV8UC3,
Channels: 3,
}
}
func TestValidate_ValidClip(t *testing.T) {
clp := validTestClip()
if err := clp.Validate(); err != nil {
t.Errorf("valid clip rejected: %v", err)
}
}
func TestValidate_EmptyClipIsValid(t *testing.T) {
clp := Clip{}
if err := clp.Validate(); err != nil {
t.Errorf("empty clip rejected: %v", err)
}
}
func TestValidate_MissingGuids(t *testing.T) {
clp := validTestClip()
clp.Guids = nil
if err := clp.Validate(); !errors.Is(err, ErrMalformedClip) {
t.Errorf("expected ErrMalformedClip, got %v", err)
}
}
func TestValidate_MissingTimestamps(t *testing.T) {
clp := validTestClip()
clp.Timestamps = nil
if err := clp.Validate(); !errors.Is(err, ErrMalformedClip) {
t.Errorf("expected ErrMalformedClip, got %v", err)
}
}
func TestValidate_ZeroDimensions(t *testing.T) {
clp := validTestClip()
clp.Width = 0
if err := clp.Validate(); !errors.Is(err, ErrMalformedClip) {
t.Errorf("expected ErrMalformedClip for zero width, got %v", err)
}
}
func TestValidate_HugeDimensions(t *testing.T) {
clp := validTestClip()
clp.Width = MaxClipDim + 1
if err := clp.Validate(); !errors.Is(err, ErrMalformedClip) {
t.Errorf("expected ErrMalformedClip for oversized dimensions, got %v", err)
}
}
func TestValidate_TooManyFrames(t *testing.T) {
clp := Clip{
PixelMats: make([][]byte, MaxClipFrames+1),
Guids: make([][]byte, MaxClipFrames+1),
Timestamps: make([]uint64, MaxClipFrames+1),
Width: 1,
Height: 1,
Channels: 1,
}
if err := clp.Validate(); !errors.Is(err, ErrMalformedClip) {
t.Errorf("expected ErrMalformedClip for excessive frames, got %v", err)
}
}
func TestValidate_InvalidChannels(t *testing.T) {
clp := validTestClip()
clp.Channels = 5
if err := clp.Validate(); !errors.Is(err, ErrMalformedClip) {
t.Errorf("expected ErrMalformedClip for channel count 5, got %v", err)
}
}
func TestValidate_EncryptedClip(t *testing.T) {
clp := validTestClip()
if err := clp.Encrypt("secret"); err != nil {
t.Fatalf("Encrypt() error: %v", err)
}
if err := clp.Validate(); err != nil {
t.Errorf("valid encrypted clip rejected: %v", err)
}
}
func TestValidate_WrongFrameSize(t *testing.T) {
clp := validTestClip()
clp.PixelMats[0] = []byte{1, 2, 3, 4} // 1x1x3 declared, 4 bytes sent
if err := clp.Validate(); !errors.Is(err, ErrMalformedClip) {
t.Errorf("expected ErrMalformedClip for undersized frame, got %v", err)
}
}
func TestValidate_OverBudgetTotalBytes(t *testing.T) {
// 22 frames of a legitimate 1024x1024x3 frame (3 MiB each) exceed the
// 64 MiB total cap even though every frame is individually well-formed.
expected := 1024 * 1024 * 3
frames := make([][]byte, 22)
for i := range frames {
frames[i] = make([]byte, expected)
}
clp := Clip{
PixelMats: frames,
Guids: make([][]byte, 22),
Timestamps: make([]uint64, 22),
Width: 1024,
Height: 1024,
Types: gocv.MatTypeCV8UC3,
Channels: 3,
}
err := clp.Validate()
if !errors.Is(err, ErrMalformedClip) {
t.Errorf("expected ErrMalformedClip for over-budget clip, got %v", err)
}
if !strings.Contains(err.Error(), "exceeds limit") {
t.Errorf("expected the total-byte error, got: %v", err)
}
}
func TestMalformedAttempts_CountsPerSource(t *testing.T) {
m := NewMalformedAttempts()
if n := m.Reject("10.0.0.1:5000"); n != 1 {
t.Errorf("first reject: got %d, want 1", n)
}
if n := m.Reject("10.0.0.1:5000"); n != 2 {
t.Errorf("second reject from same source: got %d, want 2", n)
}
if n := m.Reject("10.0.0.2:5000"); n != 1 {
t.Errorf("reject from new source: got %d, want 1", n)
}
if total := m.Total(); total != 3 {
t.Errorf("Total: got %d, want 3", total)
}
}
func TestHandleClipConn_ValidClipDelivered(t *testing.T) {
SetAuthToken("test-token")
t.Cleanup(func() { SetAuthToken("") })
server, client := net.Pipe()
defer server.Close()
defer client.Close()
delivered := make(chan Clip, 1)
go HandleClipConn(server, NewMalformedAttempts(), func(clp Clip) {
delivered <- clp
})
clp := validTestClip()
if err := writeAuthToken(client); err != nil {
t.Fatalf("auth: %v", err)
}
if _, err := writeWireClip(client, &clp); err != nil {
t.Fatalf("write: %v", err)
}
select {
case got := <-delivered:
if len(got.PixelMats) != 2 {
t.Errorf("delivered clip has %d frames, want 2", len(got.PixelMats))
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for valid clip")
}
}
func TestHandleClipConn_MalformedClipRejectedAndCounted(t *testing.T) {
// The exact shape that used to crash the daemons: a clip whose
// PixelMats outlives Guids/Timestamps. Sublimate(indexed) panics on it.
SetAuthToken("test-token")
t.Cleanup(func() { SetAuthToken("") })
server, client := net.Pipe()
defer server.Close()
defer client.Close()
rej := NewMalformedAttempts()
done := make(chan struct{})
go func() {
HandleClipConn(server, rej, func(Clip) {
t.Error("callback must not run for a malformed clip")
})
close(done)
}()
bad := Clip{
PixelMats: [][]byte{{1, 2, 3}},
Guids: nil,
Timestamps: []uint64{100},
Width: 1,
Height: 1,
Types: gocv.MatTypeCV8UC3,
Channels: 3,
}
if err := writeAuthToken(client); err != nil {
t.Fatalf("auth: %v", err)
}
if _, err := writeWireClip(client, &bad); err != nil {
t.Fatalf("write: %v", err)
}
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("HandleClipConn did not return for malformed clip")
}
if rej.Total() != 1 {
t.Errorf("rejected attempts: got %d, want 1", rej.Total())
}
}
func TestHandleClipConn_GarbageDoesNotPanic(t *testing.T) {
SetAuthToken("test-token")
t.Cleanup(func() { SetAuthToken("") })
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
done := make(chan struct{})
go func() {
conn, err := ln.Accept()
if err != nil {
t.Errorf("accept: %v", err)
return
}
HandleClipConn(conn, NewMalformedAttempts(), func(Clip) {
t.Error("callback must not run for garbage")
})
close(done)
}()
conn, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatalf("dial: %v", err)
}
if _, err := conn.Write([]byte("test-token" + "\n")); err != nil {
t.Fatalf("write auth: %v", err)
}
if _, err := conn.Write([]byte("this is not a gob stream at all")); err != nil {
t.Fatalf("write: %v", err)
}
conn.Close()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("HandleClipConn did not return for garbage input")
}
}
+102
View File
@@ -0,0 +1,102 @@
package frame
import (
"fmt"
"sync"
"time"
"gocv.io/x/gocv"
)
// isCaptureDevice returns true if idx is a real video capture device
// (not a video output, loopback, tuner, etc.) by checking for a non-zero
// frame size — only capture devices report this before streaming.
func isCaptureDevice(idx int) bool {
if idx < 0 {
return false
}
cam, err := gocv.VideoCaptureDevice(idx)
if err != nil {
return false
}
defer cam.Close()
return cam.Get(gocv.VideoCaptureFrameWidth) > 0
}
// Scan discovers working V4L camera indices in [min, max].
// Only real video capture devices are reported — non-capture V4L2 devices
// (output, loopback, tuners) are filtered out. Each device is opened and
// immediately closed; handles are never leaked.
func Scan(min, max int) ([]int, error) {
return scanRange(min, max, 1, nil)
}
// ScanSkip is Scan restricted to every skip-th index in [min, max], so a
// rebalance pass can re-probe the bus without hammering every device.
func ScanSkip(min, max, skip int) ([]int, error) {
return scanRange(min, max, skip, nil)
}
// ScanExcluding is Scan that never opens or reports the given indices, so a
// hotplug loop can re-probe the bus without touching devices it is already
// streaming from — a busy V4L handle can transiently report a zero size and
// flap the rebalance into a teardown/restart loop.
func ScanExcluding(min, max int, exclude map[int]struct{}) ([]int, error) {
return scanRange(min, max, 1, exclude)
}
// ScanSkipExcluding is ScanSkip with an exclusion set, see ScanExcluding.
func ScanSkipExcluding(min, max, skip int, exclude map[int]struct{}) ([]int, error) {
return scanRange(min, max, skip, exclude)
}
func scanRange(min, max, skip int, exclude map[int]struct{}) ([]int, error) {
if min > max {
return nil, fmt.Errorf("empty range [%d, %d]", min, max)
}
if skip < 1 {
skip = 1
}
var indices []int
var mu sync.Mutex
var wg sync.WaitGroup
for i := min; i <= max; i += skip {
if _, excluded := exclude[i]; excluded {
continue
}
wg.Add(1)
go func(idx int) {
defer wg.Done()
if isCaptureDevice(idx) {
mu.Lock()
indices = append(indices, idx)
mu.Unlock()
}
}(i)
}
wg.Wait()
if len(indices) == 0 {
return nil, fmt.Errorf("no cameras found in range [%d, %d]", min, max)
}
return indices, nil
}
// WaitGroupTimeout waits up to timeout for wg to reach zero, returning false
// if it times out so shutdown paths can bound how long they stall on workers
// that are blocked in a downstream retry.
func WaitGroupTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
return true
case <-time.After(timeout):
return false
}
}
+137
View File
@@ -0,0 +1,137 @@
package frame
import (
"fmt"
"os/exec"
"strconv"
"strings"
"testing"
)
func availableV4LDeviceIndices(t *testing.T) []int {
t.Helper()
out, err := exec.Command("ls", "/dev/video*").Output()
if err != nil {
return nil
}
var indices []int
for _, entry := range strings.Split(strings.TrimSpace(string(out)), "\n") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
var idx int
if n, err := fmt.Sscanf(entry, "/dev/video%d", &idx); err == nil && n == 1 {
indices = append(indices, idx)
}
}
return indices
}
func TestScan_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping camera scan in short mode")
}
available := availableV4LDeviceIndices(t)
indices, err := Scan(0, 15)
if err != nil {
if len(available) == 0 {
t.Skip("no /dev/video* devices found; skipping")
}
t.Fatalf("Scan returned error but V4L devices exist: %v", err)
}
t.Logf("Scan found %d device(s): %v", len(indices), indices)
seen := make(map[int]bool)
for _, idx := range indices {
if seen[idx] {
t.Errorf("duplicate index %d in Scan result", idx)
}
seen[idx] = true
}
}
func TestScan_EmptyRange(t *testing.T) {
_, err := Scan(5, 3)
if err == nil {
t.Error("expected error for empty range [5,3]")
}
}
func TestScan_SingleValue(t *testing.T) {
if testing.Short() {
t.Skip("skipping camera scan in short mode")
}
indices, err := Scan(9999, 9999)
if err == nil {
t.Log("unexpectedly found a camera at index 9999")
return
}
if len(indices) != 0 {
t.Errorf("expected empty indices for high range, got %v", indices)
}
}
func TestScanSkip_EmptyRange(t *testing.T) {
_, err := ScanSkip(5, 3, 1)
if err == nil {
t.Error("expected error for empty range [5,3]")
}
}
func TestIsCaptureDevice(t *testing.T) {
if isCaptureDevice(-1) {
t.Error("expected false for negative index")
}
}
func TestParseVideoDevice(t *testing.T) {
tests := []struct {
input string
want int
ok bool
}{
{"/dev/video0", 0, true},
{"/dev/video42", 42, true},
{"/dev/video", 0, false},
{"/dev/video-1", -1, true},
{"/dev/videoabc", 0, false},
{"video0", 0, false},
{"", 0, false},
}
for _, tt := range tests {
var idx int
n, err := fmt.Sscanf(tt.input, "/dev/video%d", &idx)
got := err == nil && n == 1
if got != tt.ok {
t.Errorf("parse(%q): got ok=%v, want %v", tt.input, got, tt.ok)
}
if got && idx != tt.want {
t.Errorf("parse(%q): got %d, want %d", tt.input, idx, tt.want)
}
}
}
func TestAvailableDevices(t *testing.T) {
indices := availableV4LDeviceIndices(t)
t.Logf("Found %d V4L devices: %v", len(indices), indices)
for _, idx := range indices {
if idx < 0 {
t.Errorf("negative device index: %d", idx)
}
if _, err := strconv.Atoi(fmt.Sprintf("%d", idx)); err != nil {
t.Errorf("invalid device index format: %d", idx)
}
}
seen := make(map[int]bool)
for _, idx := range indices {
if seen[idx] {
t.Errorf("duplicate index %d", idx)
}
seen[idx] = true
}
}
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
oko user management script.
Adds/removes end users and provisions private Matrix rooms on ayrc.online
so each user has a space to view their surveillance clips.
Usage:
python3 manage_users.py add --user alice --matrix-id @alice:ayrc.online
python3 manage_users.py remove --user alice
python3 manage_users.py list
python3 manage_users.py info --user alice
Reads the bot token from matrix.token (same file the coordinator uses).
Stores user data in users.json at the project root.
"""
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
USERS_FILE = os.path.join(SCRIPT_DIR, "users.json")
TOKEN_FILE = os.path.join(SCRIPT_DIR, "matrix.token")
MATRIX_HOMESERVER = "https://ayrc.online"
MATRIX_BOT_USER = "@okobot:ayrc.online"
# ── Matrix helpers (raw Client-Server API, no dependencies) ──────────────
def matrix_request(method, path, token, body=None):
"""Send a request to the Matrix Client-Server API and return the parsed
response. Raises on HTTP errors."""
url = f"{MATRIX_HOMESERVER}{path}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"Bearer {token}")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
err_body = exc.read().decode(errors="replace")
raise SystemExit(f"Matrix API error {exc.code}: {err_body}") from exc
def matrix_create_room(token, name, topic, invite_user):
"""Create an invite-only private room, set its name and topic, then
invite *invite_user*. Returns the room ID."""
resp = matrix_request("POST", "/_matrix/client/v3/createRoom", token, {
"name": name,
"topic": topic,
"room_version": "10",
"is_direct": False,
"visibility": "private",
"invite": [invite_user],
"preset": "private_chat",
"initial_state": [
{
"type": "m.room.join_rules",
"content": {"join_rule": "invite"},
},
{
"type": "m.room.history_visibility",
"content": {"history_visibility": "shared"},
},
],
})
return resp["room_id"]
def matrix_send_notice(token, room_id, text):
"""Send a human-readable notice into a room."""
matrix_request("POST", f"/_matrix/client/v3/rooms/{room_id}/send/m.room.message", token, {
"msgtype": "m.text",
"body": text,
})
# ── User store ───────────────────────────────────────────────────────────
def load_users():
if not os.path.exists(USERS_FILE):
return {}
with open(USERS_FILE) as f:
return json.load(f)
def save_users(users):
with open(USERS_FILE, "w") as f:
json.dump(users, f, indent=2, sort_keys=True)
f.write("\n")
# ── Commands ─────────────────────────────────────────────────────────────
def cmd_add(args):
users = load_users()
name = args.user.lower()
if name in users and users[name].get("active", True):
print(f"User '{name}' already exists and is active.")
sys.exit(1)
matrix_id = args.matrix_id
if not matrix_id.startswith("@"):
print("Matrix user ID must start with '@' (e.g. @alice:ayrc.online)")
sys.exit(1)
bot_token = open(TOKEN_FILE).read().strip()
if not bot_token:
print(f"Cannot read bot token from {TOKEN_FILE}")
sys.exit(1)
display_name = args.display_name or name
room_name = f"oko - {display_name}"
room_topic = f"Private surveillance clips for {display_name}"
print(f"Creating private Matrix room for {display_name}...")
room_id = matrix_create_room(bot_token, room_name, room_topic, matrix_id)
print(f" Room created: {room_id}")
greeting = (
f"Welcome, {display_name}! This is your private oko surveillance room.\n"
f"Clips from your cameras will appear here."
)
matrix_send_notice(bot_token, room_id, greeting)
print(f" Greeting posted to room")
users[name] = {
"display_name": display_name,
"matrix_id": matrix_id,
"room_id": room_id,
"created_at": int(time.time()),
"active": True,
}
save_users(users)
print(f"User '{name}' saved to {USERS_FILE}")
print(f"\nDone. Invite the user to log in to Matrix and accept the room invite.")
def cmd_remove(args):
users = load_users()
name = args.user.lower()
if name not in users:
print(f"User '{name}' not found.")
sys.exit(1)
if not users[name].get("active", True):
print(f"User '{name}' is already inactive.")
sys.exit(0)
users[name]["active"] = False
users[name]["removed_at"] = int(time.time())
save_users(users)
print(f"User '{name}' marked as inactive.")
print(f" Matrix room {users[name]['room_id']} was left intact.")
def cmd_list(args):
users = load_users()
if not users:
print("No users registered.")
return
active = [(n, u) for n, u in users.items() if u.get("active", True)]
inactive = [(n, u) for n, u in users.items() if not u.get("active", True)]
if active:
print(f"Active users ({len(active)}):")
for name, u in active:
print(f" {name:20s} {u['matrix_id']:30s} room {u['room_id']}")
if inactive:
print(f"Inactive users ({len(inactive)}):")
for name, u in inactive:
print(f" {name:20s} {u['matrix_id']:30s} (removed)")
if not active and not inactive:
print("No users registered.")
def cmd_info(args):
users = load_users()
name = args.user.lower()
if name not in users:
print(f"User '{name}' not found.")
sys.exit(1)
u = users[name]
status = "active" if u.get("active", True) else "inactive"
created = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(u.get("created_at", 0)))
print(f"User: {name}")
print(f"Status: {status}")
print(f"Display: {u.get('display_name', '')}")
print(f"Matrix ID: {u.get('matrix_id', '')}")
print(f"Room ID: {u.get('room_id', '')}")
print(f"Created: {created}")
# ── CLI ──────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="oko user management add, remove, and list surveillance users",
)
sub = parser.add_subparsers(dest="command", required=True)
p_add = sub.add_parser("add", help="add a new user and create their Matrix room")
p_add.add_argument("--user", required=True, help="username (lowercase, no spaces)")
p_add.add_argument("--matrix-id", required=True, help="Matrix user ID, e.g. @alice:ayrc.online")
p_add.add_argument("--display-name", default=None, help="human-readable display name (defaults to username)")
p_rm = sub.add_parser("remove", help="deactivate a user")
p_rm.add_argument("--user", required=True, help="username to remove")
p_ls = sub.add_parser("list", help="list all users")
p_info = sub.add_parser("info", help="show details for a user")
p_info.add_argument("--user", required=True, help="username to inspect")
args = parser.parse_args()
if args.command == "add":
cmd_add(args)
elif args.command == "remove":
cmd_remove(args)
elif args.command == "list":
cmd_list(args)
elif args.command == "info":
cmd_info(args)
if __name__ == "__main__":
main()
+20
View File
@@ -0,0 +1,20 @@
# oko Matrix homeserver credentials template.
# Copy to a SECRET, untracked location (e.g. ~/oko-matrix-credentials.txt,
# chmod 600) and fill in real values. Do not commit.
#
# NOTE: the coordinator reads the token from matrix.token (see .gitignore)
# or the OKO_MATRIX_TOKEN env var.
HOMESERVER = https://ayrc.online
SERVER_NAME = ayrc.online
BOT_USER = @okobot:ayrc.online
BOT_PW = CHANGE_ME
BOT_TOKEN = CHANGE_ME
VIEWER_USER = @you:your-server-name
VIEWER_PW = CHANGE_ME
VIEWER_TOKEN = CHANGE_ME
CLIPS_ROOM = !roomid:your-server-name
DETECTIONS_ROOM = !roomid:your-server-name
+1
View File
@@ -0,0 +1 @@
NUckvSv7m9CbImlz8ZCllyVC2BiZXvby
+12
View File
@@ -0,0 +1,12 @@
module mofin
go 1.26.3
require frame v0.0.0
require (
gocv.io/x/gocv v0.43.0
golang.org/x/crypto v0.52.0 // indirect
)
replace frame => ../frame
+4
View File
@@ -0,0 +1,4 @@
gocv.io/x/gocv v0.43.0 h1:PFNpRUcV8fgBRDbVHHN+4BDZjjPnVveo5N/+e15BTuA=
gocv.io/x/gocv v0.43.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
+173
View File
@@ -0,0 +1,173 @@
package main
import (
"context"
"flag"
"log"
"net"
"os"
"os/signal"
"sync"
"syscall"
"time"
"frame"
)
const (
workerCount = 4
// connLimit bounds concurrent inbound connections so a connection flood
// (auth handshake parked, or clips queued behind busy workers) cannot
// exhaust goroutines or file descriptors.
connLimit = 16
// clipChanCap gives in-flight ingest handlers somewhere to park a decoded
// clip without holding the connection's socket open for the workers.
clipChanCap = 32
shutdownGrace = 5 * time.Second
)
func main() {
var (
listenAddr string
terpAddr string
threshold int
passphrase string
authToken string
pbkdf2Iter int
)
flag.StringVar(&listenAddr, "listen", ":8083", "address to listen for incoming clips")
flag.StringVar(&terpAddr, "terp", "localhost:8081", "address to forward clips with motion to terp")
flag.IntVar(&threshold, "threshold", 30, "pixel-value sensitivity (0-255) for motion detection comparison")
flag.StringVar(&passphrase, "passphrase", "", "decryption passphrase (empty = no encryption)")
flag.StringVar(&authToken, "auth-token", "", "shared pipeline auth token (or OKO_AUTH_TOKEN)")
flag.IntVar(&pbkdf2Iter, "pbkdf2-iters", frame.DefaultPBKDF2Iter, "PBKDF2 iterations for clip encryption (1..1000000)")
flag.Parse()
if threshold < 0 || threshold > 255 {
log.Fatalf("threshold must be in [0, 255], got %d", threshold)
}
if pbkdf2Iter < 1 || pbkdf2Iter > frame.MaxPBKDF2Iter {
log.Fatalf("pbkdf2-iters must be in [1, %d], got %d", frame.MaxPBKDF2Iter, pbkdf2Iter)
}
frame.SetDefaultPBKDF2Iter(pbkdf2Iter)
tok := authToken
if tok == "" {
tok = os.Getenv("OKO_AUTH_TOKEN")
}
if tok == "" {
log.Fatal("authentication required: set -auth-token or OKO_AUTH_TOKEN")
}
frame.SetAuthToken(tok)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
clipChan := make(chan frame.Clip, clipChanCap)
connSem := make(chan struct{}, connLimit)
ln, err := net.Listen("tcp", listenAddr)
if err != nil {
log.Fatalf("Listen %s: %v", listenAddr, err)
}
log.Printf("Listening on %s", listenAddr)
var acceptWg sync.WaitGroup
rejected := frame.NewMalformedAttempts()
go func() {
<-ctx.Done()
ln.Close()
}()
go func() {
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
log.Printf("Accept error: %v", err)
continue
}
select {
case connSem <- struct{}{}:
default:
log.Printf("Rejecting connection from %s: too many concurrent connections", conn.RemoteAddr())
conn.Close()
continue
}
acceptWg.Add(1)
go func(c net.Conn) {
defer func() { <-connSem }()
defer acceptWg.Done()
frame.HandleClipConn(c, rejected, func(clp frame.Clip) {
clipChan <- clp
})
}(conn)
}
}()
// Bounded worker pool, so a burst of clips cannot spin up unbounded
// goroutines or saturate every core with motion passes.
var procWg sync.WaitGroup
procWg.Add(workerCount)
for w := 0; w < workerCount; w++ {
go func() {
defer procWg.Done()
for clp := range clipChan {
processClip(clp, terpAddr, threshold, passphrase)
}
}()
}
<-ctx.Done()
log.Println("Shutting down...")
ln.Close()
if !frame.WaitGroupTimeout(&acceptWg, shutdownGrace) {
log.Println("Inbound handlers did not drain; closing channel anyway")
}
close(clipChan)
if !frame.WaitGroupTimeout(&procWg, shutdownGrace) {
log.Println("Workers still busy (downstream retry?); exiting anyway")
}
if n := rejected.Total(); n > 0 {
log.Printf("Rejected %d malformed clips from the network", n)
}
log.Println("Exiting")
}
// processClip decrypts, drops clips with no motion, re-encrypts, and forwards
// survivors to terp.
func processClip(clp frame.Clip, terpAddr string, threshold int, passphrase string) {
if passphrase != "" {
if err := clp.Decrypt(passphrase); err != nil {
log.Printf("Decrypt error: %v", err)
return
}
}
if err := clp.CountChangedPixels(uint8(threshold)); err != nil {
log.Printf("CountChangedPixels: %v", err)
return
}
if clp.GetHighestMotion() == 0 {
log.Printf("Dropped no-motion clip (source %s)", clp.SourceData)
return
}
if passphrase != "" {
if err := clp.Encrypt(passphrase); err != nil {
log.Printf("Encrypt error: %v", err)
return
}
}
if err := clp.SendClipRetry(terpAddr, 6); err != nil {
log.Printf("Forward to terp failed, clip dropped: %v", err)
return
}
log.Printf("Forwarded clip to terp (motion: %d%%)", clp.GetHighestMotion())
}
Executable
BIN
View File
Binary file not shown.
+165
View File
@@ -0,0 +1,165 @@
package main
import (
"bufio"
"net"
"strings"
"sync"
"testing"
"time"
"frame"
"gocv.io/x/gocv"
)
func TestMain(m *testing.M) {
frame.SetAuthToken("test-token")
m.Run()
}
func startClipListener(t *testing.T) (string, chan frame.Clip) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
ch := make(chan frame.Clip, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
br := bufio.NewReader(conn)
if line, err := br.ReadString('\n'); err != nil || strings.TrimSpace(line) != "test-token" {
return
}
clp, err := frame.DecodeWireClip(br)
if err == nil {
ch <- clp
}
ln.Close()
}()
return ln.Addr().String(), ch
}
func makeTestClipBytes(t *testing.T, fill0, fill1 float64) frame.Clip {
t.Helper()
mat := gocv.NewMatWithSize(4, 4, gocv.MatTypeCV8UC1)
defer mat.Close()
mat.SetTo(gocv.NewScalar(fill0, 0, 0, 0))
bytes0 := mat.ToBytes()
mat.SetTo(gocv.NewScalar(fill1, 0, 0, 0))
bytes1 := mat.ToBytes()
return frame.Clip{
PixelMats: [][]byte{bytes0, bytes1},
Width: 4,
Height: 4,
Types: gocv.MatTypeCV8UC1,
Channels: 1,
Guids: [][]byte{{0xaa}, {0xbb}},
Timestamps: []uint64{100, 200},
}
}
func TestProcessClip_ForwardsToTerp(t *testing.T) {
clip := makeTestClipBytes(t, 0, 255)
addr, received := startClipListener(t)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
processClip(clip, addr, 30, "")
}()
wg.Wait()
select {
case got := <-received:
if len(got.PixelMats) != 2 {
t.Errorf("got %d frames, want 2", len(got.PixelMats))
}
if len(got.Comparisons) != 1 {
t.Errorf("got %d comparisons, want 1", len(got.Comparisons))
}
if got.Comparisons[0].PixelsChanged == 0 {
t.Error("expected non-zero pixel change between different frames")
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for forwarded clip")
}
}
func TestProcessClip_DropsNoMotion(t *testing.T) {
clip := makeTestClipBytes(t, 100, 100)
addr, received := startClipListener(t)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
processClip(clip, addr, 30, "")
}()
wg.Wait()
select {
case got := <-received:
t.Errorf("no-motion clip should have been dropped locally, got one with %d changed pixels",
got.Comparisons[0].PixelsChanged)
case <-time.After(500 * time.Millisecond):
}
}
func TestProcessClip_WithPassphrase(t *testing.T) {
clip := makeTestClipBytes(t, 0, 128)
frames := clip.Sublimate()
for i := range frames {
frames[i].PixelBytes = clip.PixelMats[i]
if err := frames[i].Encrypt("test-passphrase"); err != nil {
t.Fatalf("pre-encrypt frame %d: %v", i, err)
}
clip.PixelMats[i] = frames[i].PixelBytes
}
addr, received := startClipListener(t)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
processClip(clip, addr, 30, "test-passphrase")
}()
wg.Wait()
select {
case got := <-received:
if err := got.Decrypt("test-passphrase"); err != nil {
t.Errorf("decrypt of forwarded clip failed: %v", err)
}
if len(got.Comparisons) != 1 {
t.Errorf("got %d comparisons, want 1", len(got.Comparisons))
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for encrypted clip")
}
}
func TestProcessClip_InvalidAddress(t *testing.T) {
clip := makeTestClipBytes(t, 0, 255)
done := make(chan struct{}, 1)
go func() {
processClip(clip, "127.0.0.1:1", 30, "")
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("processClip hung retrying an unreachable terp")
}
}
+5
View File
@@ -0,0 +1,5 @@
module oko-run
go 1.26.3
require gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"gopkg.in/yaml.v3"
)
type Config struct {
Nodes []Node `yaml:"nodes"`
}
type Node struct {
Name string `yaml:"name"`
Binary string `yaml:"binary"`
Flags map[string]interface{} `yaml:"flags"`
}
func main() {
configPath := flag.String("config", "config.yaml", "path to pipeline configuration")
flag.Parse()
absConfig, err := filepath.Abs(*configPath)
if err != nil {
log.Fatalf("abs config path: %v", err)
}
rootDir := filepath.Dir(absConfig)
data, err := os.ReadFile(absConfig)
if err != nil {
log.Fatalf("read config: %v", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
log.Fatalf("parse config: %v", err)
}
if len(cfg.Nodes) == 0 {
log.Fatal("no nodes defined in config")
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
for _, node := range cfg.Nodes {
go runNode(ctx, rootDir, node)
}
<-ctx.Done()
log.Println("shutting down pipeline")
}
func runNode(ctx context.Context, rootDir string, node Node) {
binaryDir := filepath.Join(rootDir, node.Binary)
binaryPath := filepath.Join(binaryDir, node.Binary)
if _, err := os.Stat(binaryPath); err != nil {
log.Printf("building %s (%s)", node.Name, node.Binary)
build := exec.CommandContext(ctx, "go", "build", "-o", node.Binary, ".")
build.Dir = binaryDir
build.Stdout = os.Stdout
build.Stderr = os.Stderr
if err := build.Run(); err != nil {
log.Printf("%s build failed: %v", node.Name, err)
return
}
}
args := buildArgs(node.Flags)
// Restart a crashed node until the pipeline is told to shut down. Each
// restart is preceded by a short backoff so a crash loop does not spin.
for ctx.Err() == nil {
cmd := exec.Command(binaryPath, args...)
cmd.Dir = binaryDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Deliver SIGTERM (not SIGKILL) on shutdown so each node drains and
// closes its V4L handles / socket cleanly.
stop := make(chan struct{})
go func() {
select {
case <-ctx.Done():
cmd.Process.Signal(syscall.SIGTERM)
case <-stop:
}
}()
log.Printf("starting %s (%s)", node.Name, node.Binary)
err := cmd.Run()
close(stop)
if ctx.Err() != nil {
return
}
log.Printf("%s exited unexpectedly (%v); restarting in 2s", node.Name, err)
select {
case <-ctx.Done():
return
case <-time.After(2 * time.Second):
}
}
}
func buildArgs(flags map[string]interface{}) []string {
var args []string
for key, val := range flags {
switch v := val.(type) {
case string:
args = append(args, fmt.Sprintf("--%s=%s", key, v))
case int:
args = append(args, fmt.Sprintf("--%s=%d", key, v))
case float64:
args = append(args, fmt.Sprintf("--%s=%v", key, v))
case bool:
if v {
args = append(args, fmt.Sprintf("--%s", key))
}
case []interface{}:
var parts []string
for _, item := range v {
parts = append(parts, fmt.Sprint(item))
}
args = append(args, fmt.Sprintf("--%s=%s", key, strings.Join(parts, ",")))
default:
args = append(args, fmt.Sprintf("--%s=%v", key, v))
}
}
return args
}
BIN
View File
Binary file not shown.
Executable
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# run-stack.sh — Test-only launcher for the oko clip pipeline.
#
# Starts processes from the "backend" (coordinator) working toward runCam so
# that each downstream consumer is listening before its upstream producer
# connects. For testing, not production.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
LOGDIR="$ROOT/logs"
mkdir -p "$LOGDIR"
# Shared pipeline auth token (override in the environment).
: "${OKO_AUTH_TOKEN:=$(openssl rand -hex 16)}"
# PBKDF2 iterations for clip encryption (lower = less CPU, weaker brute-force resistance).
: "${OKO_PBKDF2_ITERS:=600000}"
# runCam capture settings; 0 = keep the device default.
: "${OKO_VIDEO_FPS:=5}"
: "${OKO_VIDEO_WIDTH:=0}"
: "${OKO_VIDEO_HEIGHT:=0}"
# Matrix backend settings for coordinator (override in the environment).
: "${OKO_MATRIX_HS:=https://ayrc.online}"
: "${OKO_MATRIX_USER:=@okobot:ayrc.online}"
: "${OKO_MATRIX_TOKEN_FILE:=$ROOT/matrix.token}"
: "${OKO_CLIPS_ROOM:=!0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg}"
: "${OKO_DETECTIONS_ROOM:=!XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ}"
: "${OKO_VIEW_ROOM:=!wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE}"
PIDS=()
cleanup() {
echo "Shutting down pipeline..."
for pid in "${PIDS[@]}"; do
kill "$pid" 2>/dev/null || true
done
wait 2>/dev/null || true
}
trap cleanup INT TERM EXIT
# build_if_missing <name> — build the binary in its dir if absent.
build_if_missing() {
local name="$1"
local binary="$name/$name"
if [[ -x "$binary" ]]; then
return
fi
echo "Building $name..."
(cd "$name" && go build -o "$name" .)
}
# start <name> <logfile> [args...] — launch in background and record PID.
start() {
local name="$1"
local log="$2"
shift 2
echo "Starting $name (log: $log)"
(cd "$ROOT/$name" && exec "$ROOT/$name/$name" "$@") >>"$log" 2>&1 &
PIDS+=("$!")
}
# --- Build any missing binaries --------------------------------------------
build_if_missing coordinator
build_if_missing terp
build_if_missing mofin
build_if_missing runCam
# --- Start pipeline from backend toward runCam -----------------------------
# coordinator: Matrix-backed storage, listens :8082
start coordinator "$LOGDIR/coordinator.log" \
"--listen=:8082" \
"--auth-token=$OKO_AUTH_TOKEN" \
"--pbkdf2-iters=$OKO_PBKDF2_ITERS" \
"--matrix-homeserver=$OKO_MATRIX_HS" \
"--matrix-user=$OKO_MATRIX_USER" \
"--matrix-token-file=$OKO_MATRIX_TOKEN_FILE" \
"--clips-room=$OKO_CLIPS_ROOM" \
"--detections-room=$OKO_DETECTIONS_ROOM" \
"--view-room=$OKO_VIEW_ROOM"
sleep 1
# terp: classifier, listens :8081, forwards to coordinator
start terp "$LOGDIR/terp.log" \
"--listen=:8081" \
"--sendoff=localhost:8082" \
"--auth-token=$OKO_AUTH_TOKEN" \
"--classifiers=classifiers/*.xml" \
"--motion-percent=5" \
"--pbkdf2-iters=$OKO_PBKDF2_ITERS"
sleep 1
# mofin: motion detection, listens :8083, drops no-motion clips, forwards to terp
start mofin "$LOGDIR/mofin.log" \
"--listen=:8083" \
"--terp=localhost:8081" \
"--auth-token=$OKO_AUTH_TOKEN" \
"--threshold=30" \
"--pbkdf2-iters=$OKO_PBKDF2_ITERS"
sleep 1
# runCam: camera source, connects to mofin — started last
start runCam "$LOGDIR/runCam.log" \
"--mofin=localhost:8083" \
"--auth-token=$OKO_AUTH_TOKEN" \
"--min-cam=0" \
"--max-cam=10" \
"--clip-duration=10s" \
"--rescan-interval=10s" \
"--user=oko" \
"--camera-id=front" \
"--video-fps=$OKO_VIDEO_FPS" \
"--video-width=$OKO_VIDEO_WIDTH" \
"--video-height=$OKO_VIDEO_HEIGHT" \
"--pbkdf2-iters=$OKO_PBKDF2_ITERS"
echo "Pipeline up. Press Ctrl-C to shut down."
wait
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
cameras:
- user: "oko"
camera_id: "front"
index: 0
passphrase: ""
livestream:
backend_addr: "localhost:8085"
fps: 5
backend:
listen_tcp: ":8085"
listen_https: ":8443"
cert_file: "tls/cert.pem"
key_file: "tls/key.pem"
cache_ttl: "30s"
log_file: "/home/nolan/dev/oko/run/logs/livestream-cache-frames.log"
+19
View File
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDCTCCAfGgAwIBAgIUUxekvynx2yBBLEX0ZMaiUBVdVvYwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJb2tvLWxvY2FsMB4XDTI2MDgyNDA0MzMzNloXDTI3MDgy
NDA0MzMzNlowFDESMBAGA1UEAwwJb2tvLWxvY2FsMIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAkWsH1CjoPmi3ZcslH4dX6mZxsIEleiEj9DN32C/VNvj3
luk4vuPfOmJewmFWSSh6kmcO0kJHfeSQ0Tpv2rmjmI3DZB1cu1n7piNIjJhL+j1j
en90GQuawPstY+TJDX6aRfjwkgdImGguIhlPyCRbt2mRtyxIcnDa2qjNksgwmeC4
XASQ1nuTBIX/je1lin4Cyl63uv+DaxIo3WD1yHphUhJNZxHfGb7yjGKKkzbYIiRX
JdhX4vDRF+eV9xwskgHkrENdZHbH1Q2MU8o39tYoYD0iEi3yGmEjY0N9Rj2Z/Al7
EY4UZ+ZSIO/9/zbrtKcxen1oUCPBiUL4x3eia5t5ewIDAQABo1MwUTAdBgNVHQ4E
FgQUfAgk7GkUTR8vJs8bkTGz6+l9fJcwHwYDVR0jBBgwFoAUfAgk7GkUTR8vJs8b
kTGz6+l9fJcwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAZc9V
/RsJJR72IHgIYhgrQFuI/AA2lo8VISbAKVgQC/DHl+kAvRYMvzY1W7B4d8r7BjHD
OgpWDTM7gqcgLl0kaNQRFZyMJdnW7lZWagQaDVVINrO8K63pKERoz/vQCVrMwc3D
3+PBZlQAOafUUdrsjXXPdIvEiPR0IQ3J1lQMdWbd9IF2Qgi+5Aepuqrg3VGlJOGm
A2QoHadS9nwYahEsxsk7h1Y/H5G2xwl0vUvrwg/dg8QM0U7wmrQFo9T47orDDhb5
iJFE0ci7bejf5tKdLQ3xZUQgV8uRHoMPhJSiAdV8GvgkpJkxBoKa8fCBwD/4Beu2
dNr6putfQrUFE8+VtA==
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCRawfUKOg+aLdl
yyUfh1fqZnGwgSV6ISP0M3fYL9U2+PeW6Ti+4986Yl7CYVZJKHqSZw7SQkd95JDR
Om/auaOYjcNkHVy7WfumI0iMmEv6PWN6f3QZC5rA+y1j5MkNfppF+PCSB0iYaC4i
GU/IJFu3aZG3LEhycNraqM2SyDCZ4LhcBJDWe5MEhf+N7WWKfgLKXre6/4NrEijd
YPXIemFSEk1nEd8ZvvKMYoqTNtgiJFcl2Ffi8NEX55X3HCySAeSsQ11kdsfVDYxT
yjf21ihgPSISLfIaYSNjQ31GPZn8CXsRjhRn5lIg7/3/Nuu0pzF6fWhQI8GJQvjH
d6Jrm3l7AgMBAAECggEAQLjjfiFK5OQUpIDbIAJMNe6ssUN527a+WuVBdP4tJs9+
2zMoZ4XiiAjaZieOkPcsPtcRuLF5QmYlsooQ2MgBMVf/XCLZcCWnpJVVqAXs4w7J
pO7vTGpm0Xe6BxkABzqPDY+z+zq0q+jKh7C+WrUcATFCgsV55T2nmuTAwpDJWOMJ
Q/Eubd5ADnZnIo/C42+ZKQABLi7Q+ykHx/AKRaU9AkNMR875xRYPDnzbGbubfnrW
qagxvjv6OMXKyeIc7FuQHOrC2kefsd9y5GCayb9ukcPATuyWWJRRiEvbB0qUXOJf
lAbtxJgQLa0e4P7IlWKku3PRnortei/309NTigb20QKBgQDEXNaQVbKMpK533kEU
klwhRpKjDL6d9OcoYOhNE58b+N/ihPFLAFuQYmA4CzdiWtySnfWhOtgiGhD16JsF
H51LEGkGzHlX3Lt5gZQxsqkZfy2MNGZpTWoE0aBPeE5j4GEirdc3I46UbUcbnHCd
OU+RoHQA1XuG4BPhK+aI+mD6wwKBgQC9lUL98J4S22SCcC9scBNgg3HW/4p6AMhx
Z3iLBv2iOaAZx39ObCbMZer2jsjeXdQXzB1qZPWHqGHOTXVysSGocg+CCWdcR7PG
YT6/Ls2uh5dNvFDW5FcEwsF467LimJwvk1bwi1G9bFk3N8glALvA+SB305NDFdLX
yYcVD0vq6QKBgHohrCqVwroLa6COkjfzWG4x8N6M/LpOKsBIlfPqslm5j7baLSIT
FOBW77Wr9ESucuaWbMJdUTTUtZvdkx26riCUEl7yKr7lQY2vdLb8goSjP3cm2FPC
nKBmqakEEO26D3B8Y5GeAf57gXDA+qIvlTfkboJeRbh8AqFYpsinwk31AoGAXsx7
ckXWlXFVYhA89olYdyNjUnH3yagIkwR9d2+odm1ATIPAefOEtyKjimS9tsvOWZP8
hY40ZIBjX+jFuPMv9G4MqSaQo8lK8QEBglIiEYrFst5ewgXuKXhFjHS3v602NGgF
ykSwO/so5X6kOzSRrh4lCSYn9hy8kKC6RmKewLkCgYEAqKGqi2BB8dIGEV/FNxPU
B3P+CYWXkJDFiUBEChHV26ok2An7N93Y6FghIC/fnM8fRygWALu751VtWd1rvegn
LH0QwJWzmQgswsJACFJS2SPr3XB/YcjhuNjuN21eKP4QTd3g8AnL4+mR2gcFRnW5
EJYCYWNhLRxunAp/nQ+zKog=
-----END PRIVATE KEY-----
+517
View File
@@ -0,0 +1,517 @@
2026/09/09 16:55:20 Matrix backend ready
2026/09/09 16:55:20 Listening on :8082
2026/09/09 16:55:20 decode error from 127.0.0.1:43616: auth handshake read: EOF
2026/09/09 16:55:22 decode error from 127.0.0.1:43628: auth handshake read: EOF
2026/09/09 16:55:22 decode error from 127.0.0.1:43636: auth handshake read: EOF
2026/09/09 16:56:16 Exiting
2026/09/09 16:56:32 Matrix backend ready
2026/09/09 16:56:32 Listening on :8082
2026/09/09 16:56:32 decode error from 127.0.0.1:48886: auth handshake read: EOF
2026/09/09 16:56:34 decode error from 127.0.0.1:48894: auth handshake read: EOF
2026/09/09 16:56:40 Stored clip (12 frames, motion=100%, 10.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $e6KGxon6TadtatkdpXBaBEjAIMjVMMvEynxzz8blkA4
2026/09/09 16:56:40 Stored clip (12 frames, motion=100%, 10.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $ZuQnpRoY5aXixuJWYB2uGXT3prO94vvuz88xlroq1to
2026/09/09 16:57:13 Exiting
2026/09/09 17:01:09 Matrix backend ready
2026/09/09 17:01:09 Listening on :8082
2026/09/09 17:01:09 decode error from 127.0.0.1:50208: auth handshake read: EOF
2026/09/09 17:01:10 decode error from 127.0.0.1:33468: auth handshake read: EOF
2026/09/09 17:01:33 Stored clip (12 frames, motion=100%, 10.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $ASvxlP_4M-0XKI3lk7ExeRwE5uAhxsEy03qS6Lyd3Ww
2026/09/09 17:01:34 Stored clip (12 frames, motion=100%, 10.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $xMt1dh6TgGuhXbzrz3-aOuFXH11j4La4MEd1zBYXoTw
2026/09/09 17:08:12 decode error from 127.0.0.1:35034: auth handshake read: EOF
2026/09/09 17:08:26 Exiting
2026/09/09 17:08:28 Matrix backend ready
2026/09/09 17:08:28 Listening on :8082
2026/09/09 17:08:28 decode error from 127.0.0.1:36464: auth handshake read: EOF
2026/09/09 17:08:29 decode error from 127.0.0.1:36472: auth handshake read: EOF
2026/09/09 17:10:11 Exiting
2026/09/09 17:12:27 Matrix backend ready
2026/09/09 17:12:27 Listening on :8082
2026/09/09 17:12:27 decode error from 127.0.0.1:36482: auth handshake read: EOF
2026/09/09 17:12:28 decode error from 127.0.0.1:36496: auth handshake read: EOF
2026/09/09 17:17:35 Stored clip (50 frames, motion=23%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $GYuecLBnASq_7hJEu8UCuXDkeFMKS_rUZNfxET_r8EI
2026/09/09 17:17:36 Stored clip (50 frames, motion=23%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $jI_MzxHEAf4XPSu2gotXDOWiZBWNYybKoRCfRe3lJ4k
2026/09/09 17:17:46 Stored clip (49 frames, motion=73%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $NCkrzQ2GLR1Ek23L028ACU7OfPcsy3gxSt0p_jG4zL0
2026/09/09 17:17:46 Stored clip (49 frames, motion=73%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $uw8ljIN4By4NYJAvIns9MtRO-Jyf-pdAYxggUOafG7o
2026/09/09 17:17:54 Stored clip (51 frames, motion=97%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $i1JwKeNid5s8TAhA8QEU8CMzoM3M5oGkxv4Vg5mDR_c
2026/09/09 17:17:55 Stored clip (51 frames, motion=97%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $bCKzNIw1vi8zWzhseeccwWcAMQ8S1BJ3j4XZqmZ9owc
2026/09/09 17:18:04 Stored clip (49 frames, motion=74%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $ZpSGUzeaJxdOnWnOCN70acIBBuz_KnZZx1kCONPIm2U
2026/09/09 17:18:05 Stored clip (49 frames, motion=74%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $tPOQvYRb9sxcZPJbWYIti9a5YQ5paMF2rYnLFzFDLxg
2026/09/09 17:18:19 Stored clip (50 frames, motion=43%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Tn7vuKxHhngoMQOM8HsxbvWpF4D7jCn0V61Xxs5y9do
2026/09/09 17:18:20 Stored clip (50 frames, motion=43%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $XsfBjknE0alalE7HY3vMRmwmAqyueDrWRctxg1QjwU0
2026/09/09 17:18:26 Stored clip (51 frames, motion=32%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $pThYrjsRhstCqMTuQPGUgvICppcV678B34Tb3AwNcNY
2026/09/09 17:18:26 Stored clip (51 frames, motion=32%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $oS3XYb_hOvydkCQcC0SdAcAl0e0b0naTkVO2htlpXkU
2026/09/09 17:18:39 Stored clip (50 frames, motion=35%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Lx8NoBHUfnu8VNY6vsuu4QeCWAoeYAXXM15giPGgf60
2026/09/09 17:18:39 Stored clip (50 frames, motion=35%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $nqQsMTtSxv-7KerhdOFhJoMJFEwMJWoR7nvxwjyAg74
2026/09/09 17:18:49 Stored clip (49 frames, motion=10%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $qtDhxhlKR4DEsROqJvroSvp-j5PtbVwDKTIfzVpfgV8
2026/09/09 17:18:49 Stored clip (49 frames, motion=10%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $VMMNdKFQYeDPGSQujxNf11XHPj0sW4m5lgJHQbfW4UE
2026/09/09 17:18:56 Stored clip (50 frames, motion=15%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $y0COvoawrQDvrhpQHtGagYDqhQdphLUGFHKKm6L4iKc
2026/09/09 17:18:56 Stored clip (50 frames, motion=15%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $DiIEdPotnTPLJh7avGwlXGReDd_s8iecpBgUzf56EOM
2026/09/09 17:19:05 Stored clip (50 frames, motion=3%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Z4yaIqSvIex0XALA_n6hiJS09Bnzkhbr8nZvKqOkLoo
2026/09/09 17:19:05 Stored clip (50 frames, motion=3%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $5wIqj-cDnMF7Sw5ej7r5MCX3FGE4P7IsixkYmh7jQv4
2026/09/09 17:19:15 Stored clip (51 frames, motion=40%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $jZtlGd_0tYJxlbwN7Rz-73NUcGZ1qViX2HRkKhlUkZQ
2026/09/09 17:19:15 Stored clip (51 frames, motion=40%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $Qf3HXrQEelYvMBhe1aEG_vmS9wQ888L61IfJ6npTU2Y
2026/09/09 17:19:24 Stored clip (50 frames, motion=50%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $CULEQAVx1ODrjHnZZ5UKI_F9bqhnFL9wVtLjHl36J-g
2026/09/09 17:19:25 Stored clip (50 frames, motion=50%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $C88oNs6k-esYMrStLz-puoW7ozxEUlmUbvsi812Mgf4
2026/09/09 17:20:44 Stored clip (51 frames, motion=64%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $o2RuDQyj2a9HLwKLZBgnutirsmTqYQ4FNP4tAhIVML8
2026/09/09 17:20:45 Stored clip (51 frames, motion=64%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $7CPa3y5lSnpv9u8SWfBtkii_7-0I82pvYhn6K2H7RIc
2026/09/09 17:20:56 Stored clip (50 frames, motion=18%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $NFiKWkwTrl3OizSkQePCgBVQcXEHjp7zHGivxG80CQY
2026/09/09 17:20:56 Stored clip (50 frames, motion=18%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $rEPBrjnQ4L0uZn9tAnUpBgMNw05dShnQVizETWA66PE
2026/09/09 17:21:05 Stored clip (49 frames, motion=34%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $CfinYsZzp9S1zPSD9FyXtvWSE7tptcceiW9ObnO6fCY
2026/09/09 17:21:05 Stored clip (49 frames, motion=34%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $BomY5RSin3NRVA0IPsJ33rE66GzyStdzpY0cVs7xddY
2026/09/09 17:21:14 Stored clip (51 frames, motion=11%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $U-qZeAd5LtkYXjZ_A3U0isGJF9VmV6Lw2m_Hrbq4sXY
2026/09/09 17:21:28 Stored clip (49 frames, motion=12%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $rtIw6r-nQhJXai05xkxgQydh6eHtOTKJu0ig1iNU4Co
2026/09/09 17:21:35 Stored clip (51 frames, motion=20%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $0_N6w6OJRaORIV5OyjmTn3hIREb87vnpOt8KBS1LyqY
2026/09/09 17:21:36 Stored clip (51 frames, motion=20%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $-9GBqgeCBDMXWWHP0za-5H6TUiKtIfZgTTcFVVEfYyU
2026/09/09 17:21:49 Stored clip (50 frames, motion=15%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $EJswohVXEJPqhMpW9yeSr3StRPP_fHdeqYYTSw6Q1AU
2026/09/09 17:21:49 Stored clip (50 frames, motion=15%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $oDQcM78gPW4Tm619dfZYki96riJ9hBoWQXIHqxATA50
2026/09/09 17:21:55 Stored clip (49 frames, motion=49%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $vThp-iJOjk39BajZAF-BkrmmmigqK94mZbaQSi88v80
2026/09/09 17:21:55 Stored clip (49 frames, motion=49%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $1PDRyfhE9GAiwD6CeLUZkzK-_jYPFqQWSrklxFK1hkU
2026/09/09 17:25:54 Stored clip (50 frames, motion=26%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Mkyb6icI5E3qrktMGKuOy-c-USzzx1GcrW0xHKG56S4
2026/09/09 17:25:55 Stored clip (50 frames, motion=26%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $rLmJrHVZ2rT14tX8bwB6LozF4sjbQ4LBWvA8PCjFdBA
2026/09/09 17:26:04 Stored clip (49 frames, motion=48%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $d4UX-k-ZQ-roLyEcFMma95YCsqwHY-TIdZlOoNZxEJA
2026/09/09 17:26:05 Stored clip (49 frames, motion=48%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $D0IdDnTgs7fOiqI8ozN_59CM-sfeZz6tA9w-KVPmy48
2026/09/09 17:26:15 Stored clip (51 frames, motion=36%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $piLH3mT_Jo3pb_1pzYfJMqpa5ir2rbTHhBhfehhL_gQ
2026/09/09 17:26:15 Stored clip (51 frames, motion=36%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $XKKFyXXc4XTpbxF-GszYEjwiNxHMfDfp7mkGvzxlrHI
2026/09/09 17:26:25 Stored clip (49 frames, motion=21%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $2qDKXJWpw0hLUOKScGxJgtFMOdulWdABQ_GQ7yQIJBs
2026/09/09 17:26:25 Stored clip (49 frames, motion=21%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $fZ1wxW3D86grjf10j5J6QlNoAieFnREuUJSW8h5qhvE
2026/09/09 17:26:35 Stored clip (51 frames, motion=27%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $MNsvfLPYEOILj-Ykt17le4KLwq4g1iYnIT-ljYchGYg
2026/09/09 17:26:35 Stored clip (51 frames, motion=27%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $qCpDr57z_S13qgIGjS0v0NUDWLMnBQFdAlgKNwYEz_Q
2026/09/09 17:26:44 Stored clip (50 frames, motion=53%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $8JteZbMQFdyhTg7q6ALjqtI872cvFUc9sXjBJVKh1fw
2026/09/09 17:26:45 Stored clip (50 frames, motion=53%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $rqVgo8_elbigcdyDboQe8gHCq9zdCb7t6tjb-J9GYFU
2026/09/09 17:26:59 Stored clip (50 frames, motion=37%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $6UK_OVoNLXbGPnhyTBzx5BUO-wkq4q4g4cgaBMC4cnQ
2026/09/09 17:26:59 Stored clip (50 frames, motion=37%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $DdTUY_pblGCtqbHPNsATOwJMOBFY8Q1Puo8oEuVgylI
2026/09/09 17:27:05 Stored clip (49 frames, motion=52%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Z6cuUmwmtO95iKisZtACZMRoUIvknHqealM2EeO4WUA
2026/09/09 17:28:00 Exiting
2026/09/09 17:28:01 Matrix backend ready
2026/09/09 17:28:01 Listening on :8082
2026/09/09 17:28:01 decode error from 127.0.0.1:59126: auth handshake read: EOF
2026/09/09 17:28:03 decode error from 127.0.0.1:59142: auth handshake read: EOF
2026/09/09 17:29:18 Stored clip (49 frames, motion=10%, 4.3 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $QT9Bd4m_tTx2KDp44zP_Y1QCEgugo3G7GZypCqGNUTA
2026/09/09 17:29:18 Stored clip (49 frames, motion=10%, 4.3 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $0eyhPdgegOFTkc5IHyAF742q3jBee_tDXdXhNRZOCnA
2026/09/09 17:29:33 Stored clip (50 frames, motion=39%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $RE4-NT3RhFT8G9ogd7PtNiuKL9zKr9lG_84cPcuqTs4
2026/09/09 17:29:33 Stored clip (50 frames, motion=39%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $Gv0AioGT2jvbe74-hoDl0AtiyyK86o2_BfLWiqZUx1o
2026/09/09 17:35:27 Stored clip (51 frames, motion=1%, 4.3 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $BF2m2rzKRD_ogFraJiqz8wZ24Q7Qlbcig_XHr2teXDk
2026/09/09 17:35:43 Stored clip (49 frames, motion=61%, 4.3 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $qfvKRINVJmZ82z1XsBF8Ltmpxr4VXAkKbBcSPe717ug
2026/09/09 17:35:43 Stored clip (49 frames, motion=61%, 4.3 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $B8-QaBES4Fm5wTvgiWj-NfbUsTRZ0CS144ex7UfUu8c
2026/09/09 17:35:53 Stored clip (50 frames, motion=65%, 4.3 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $NAJDUg1DdMVdB0f5HwOVAOHWRhsLqUgmHl35TBeIfJY
2026/09/09 17:35:53 Stored clip (50 frames, motion=65%, 4.3 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $H44Afy3bBfpcpfMsGtPokgfcLIRKkHFX0CulHIJpqPw
2026/09/09 17:35:58 Stored clip (51 frames, motion=6%, 4.3 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $ScLcluXDmODEncGyzVs6CcXe0r-Ud_17o_ZEVslv39Y
2026/09/09 17:35:59 Stored clip (51 frames, motion=6%, 4.3 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $4DC12ywup5siWyVbgGJfsCWFsuw6ZAXj43EueOJtSBk
2026/09/09 17:36:08 Stored clip (50 frames, motion=43%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $4aEAQcv01UQvpPaZTBlvNojp66twDAoJ1YF5ucKh-m4
2026/09/09 17:36:09 Stored clip (50 frames, motion=43%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $wYsJa53z-KeQJd2Btx4TD_jZ0BYgcl2QT1-Uki40990
2026/09/09 17:39:23 decode error from 127.0.0.1:46564: auth handshake read: EOF
2026/09/09 17:39:39 Stored clip (12 frames, motion=100%, 10.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $0wcNo29LopGfSOwJ7UG15Z0kA1MwD6iIv0RmD_-qmLc
2026/09/09 17:42:17 Exiting
2026/09/09 17:43:06 Matrix backend ready
2026/09/09 17:43:06 Listening on :8082
2026/09/09 17:43:06 decode error from 127.0.0.1:34728: auth handshake read: EOF
2026/09/09 17:43:22 Stored clip (45 frames, motion=9%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $X5NxEU4O29uvCCe3TfSaoGZUx1IdzRRjNMXz7vwH1p4
2026/09/09 17:43:23 Stored clip (45 frames, motion=9%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $pYzLKhISdyTECiN_dj0rzxHKjupFZG5XnIpgLHZuGqM
2026/09/09 17:43:33 Stored clip (51 frames, motion=20%, 4.3 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $k6B5xzCDYxraeR0pNayt3P_OG7wZyLqb1cXwk8YgmuM
2026/09/09 17:43:33 Stored clip (51 frames, motion=20%, 4.3 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $IgZNhWpi_yMpHRK5c5Tg5RdWNwQdY5Rlcgvgz4_9P3s
2026/09/09 17:43:48 Stored clip (49 frames, motion=11%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $HghKNydn3aIalohHmFeAerCZrNBX9BxxvVEcZNqX8T8
2026/09/09 17:43:48 Stored clip (49 frames, motion=11%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $iOhulG2Ep6Tx6exwYD-dUrOsY4U-Cm4h62fllsXibUc
2026/09/09 17:43:55 Stored clip (51 frames, motion=5%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Lu0ARe5uOfYYHSp0OzxLWengTSxh9PIVWJ27Q6DdYPQ
2026/09/09 17:43:55 Stored clip (51 frames, motion=5%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $JUydQr-mInC2_ru8S8Z-7lb76I4Zippx5kDR-ZxrZyw
2026/09/09 17:44:03 Stored clip (49 frames, motion=4%, 4.3 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $sBGPAZkN557f8O1Eqtm8_kk7LnxQHItXidt3wFvkRqs
2026/09/09 17:44:04 Stored clip (49 frames, motion=4%, 4.3 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $66gfKrcEXDj7t1GQbABDEajx08pqGbUgkDUf0pPTX0k
2026/09/09 17:44:12 Stored clip (50 frames, motion=47%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $vlwf5ZlP1EPllfic28kqF0I-P86JG1Lr4Y5yywrR-lc
2026/09/09 17:44:13 Stored clip (50 frames, motion=47%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $ZnEfoGhhLCpTq14-trQgOM0gTJC13C6MrshhBBvxNNc
2026/09/09 17:45:38 Stored clip (12 frames, motion=100%, 10.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $YhOGeY19w7cELp_BUewjjUuH1ebpeonXsdqOdwBOtog
2026/09/09 17:47:23 Exiting
2026/09/09 17:47:31 Matrix backend ready
2026/09/09 17:47:31 Listening on :8082
2026/09/09 17:47:31 decode error from 127.0.0.1:33436: auth handshake read: EOF
2026/09/09 17:47:48 Stored clip (45 frames, motion=27%, 4.3 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $JG6pnjdhcal1I6ywkGn7XnzM5JXsc6mMPIujklnWJYQ
2026/09/09 17:47:48 Stored clip (45 frames, motion=27%, 4.3 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $PKgr13wZhZtKxiwJxiGDElEXzDFoPeY0yPI0ATsX8T0
2026/09/09 17:47:58 Stored clip (50 frames, motion=88%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $akhvGzP7birwRO_jKMaVNv2Bb0QcQqGyQv20GTre9MU
2026/09/09 17:47:58 Stored clip (50 frames, motion=88%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $GL15f7jrQlobyabcwRy28KP6H99P1hv0b9As3fS8g2U
2026/09/09 17:48:27 Stored clip (50 frames, motion=1%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $5cg8gKnvpCsE_ETmcvJy-8VUiPECvCQF8UJ1jNgHGrw
2026/09/09 17:48:57 Stored clip (50 frames, motion=1%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $b8RiLR1D0fgAf-q1JJVQa5uPlBz2Wq1vIZJOtLtMLC0
2026/09/09 17:49:07 Stored clip (49 frames, motion=1%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $N_XhisKudSWwu68dqiqLC8C5NbhAhv9uIGJE92_P4l4
2026/09/09 17:50:04 Exiting
2026/09/09 17:50:32 Matrix backend ready
2026/09/09 17:50:32 Listening on :8082
2026/09/09 17:50:32 decode error from 127.0.0.1:58064: auth handshake read: EOF
2026/09/09 17:51:37 Stored clip (49 frames, motion=1%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $-74doSec_v05iV7tlebkWmSya0WmoW9qixOtKYZfl84
2026/09/09 17:53:18 Stored clip (50 frames, motion=5%, 4.3 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $65546-L1Q9HsKrt0KzGS2Qoor-rOci8XN0x_U71B2vc
2026/09/09 17:53:34 Stored clip (51 frames, motion=56%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $NNUW5eSgoUA9WI1sqY0ViM_0P7ivTfTSP_Tpr91LI84
2026/09/09 17:53:34 Stored clip (51 frames, motion=56%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $JF-S4gZgIEaLTVgMHGN1A0YlGvZoYOa2KQEtlViF0Nk
2026/09/09 17:53:41 Stored clip (50 frames, motion=35%, 4.3 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $hkhhWrADYOqfyqM-6tdTMOkOwgrVtIDixmfFvxu1qtA
2026/09/09 17:53:41 Stored clip (50 frames, motion=35%, 4.3 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $-Vc2MyvppztOWEB2W7La_DNcXscCSRyowaQ5YBahbBk
2026/09/09 17:53:50 Stored clip (50 frames, motion=4%, 4.3 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Kim_8T8BDBwlkLjmc10Wam59J1bAlNaKu4D5xLwvjFA
2026/09/09 17:53:50 Stored clip (50 frames, motion=4%, 4.3 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $64hUQX4bX-hOFNJ5SrQq73nhOLliVFdmeVNhEKOrjpM
2026/09/09 17:53:59 Stored clip (50 frames, motion=14%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $pcddcBCgS81skCNgC5gT_QZLBb_NZ-3qlvqy4URpiIU
2026/09/09 17:54:00 Stored clip (50 frames, motion=14%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $Nj-C4J-ymJZpizwZXoW0iP1Kn9AEysQ-SoE2fabJFBk
2026/09/09 17:54:14 Stored clip (49 frames, motion=7%, 4.3 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $A7E7hdyguGUd67xcP-TO9dVLbklAFvVF99iinTt_mHs
2026/09/09 17:54:14 Stored clip (49 frames, motion=7%, 4.3 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $jr7B_StYy_cbdSBXabgQ7nattmzBdNFHCC-fs-mRX04
2026/09/09 17:54:21 Stored clip (51 frames, motion=38%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $_OB36kbsIoo7pRvwbrc2ipj1hWa89LmDebsOWE2lrRM
2026/09/09 17:54:21 Stored clip (51 frames, motion=38%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $c0q3oRUqgsLERZaaugc4K2-w5Zan1kdclwjXm8NiEAg
2026/09/09 17:54:30 Stored clip (50 frames, motion=15%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $SwLcPOq9jmMAAqCk8Ttn-BiRxtOKcr92kXLrIHzIGhM
2026/09/09 17:54:30 Stored clip (50 frames, motion=15%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $jP6HXoYdgfGTr9hjy60Na6HY_QwA_6wzNKVGpMiZ2dA
2026/09/09 17:54:40 Stored clip (49 frames, motion=18%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Y3h0c1FwDWAlKxUqR1YTSujUVja1RWycFZFSVQB0nlQ
2026/09/09 17:54:40 Stored clip (49 frames, motion=18%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $fkDzemOadQV_WLM4vJeTSnymSXY-tvo2t6VlbR6KKnw
2026/09/09 17:54:49 Stored clip (50 frames, motion=12%, 4.5 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $5ra_KKeBx5URASatTFOedvdHNm7MmKpDKYfF_Npvj5I
2026/09/09 17:54:49 Stored clip (50 frames, motion=12%, 4.5 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $mXOLqu-3sx5rTnARubj_L3TlSw0mw-goMRKPavDUSoQ
2026/09/09 17:54:59 Stored clip (51 frames, motion=32%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $kUjEHSBwU_rsel3ikxasvzYk1mXNgn0yE3lmbyZkNMM
2026/09/09 17:55:00 Stored clip (51 frames, motion=32%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $_gupsKBqUebEupVi-Bhd1fDugmbP-cF8l0pRCIIOxQI
2026/09/09 17:55:09 Stored clip (49 frames, motion=12%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Bz42RMpATppY7M0gt9GSImvD7srWZVzEwojO7tWNBPM
2026/09/09 17:55:09 Stored clip (49 frames, motion=12%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $-f8syu-ihHt9UKjE3F82sfz3pFSgCkHSxCOFUXozBcA
2026/09/09 17:55:25 Stored clip (51 frames, motion=2%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $qmcQfzrXXa88cFumXpvnqOuTZIF4uycXYDF3_FvMFZ8
2026/09/09 17:55:25 Stored clip (51 frames, motion=2%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $0gvp1gDu0Dfc3IpjBVAgHqs6b8ZfLdW2lkVv5XVdnhM
2026/09/09 17:55:30 Stored clip (50 frames, motion=47%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $xK6wUU5BnIQybyUs_gjh9iOQeibDmNqXyA8Eh9I9Ro0
2026/09/09 17:55:30 Stored clip (50 frames, motion=47%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $_MOE57jzjiO5S8b-6l4nBpBHtE4mZrOaX8SKu8RSOso
2026/09/09 17:55:37 Stored clip (50 frames, motion=27%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $qpbtbOCz0x49eJZ95Ta_aW9FQlZ0Dh-JPl7hRBIraJE
2026/09/09 18:13:49 Stored clip (50 frames, motion=46%, 4.3 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $T6JaOZ7y1f8UthGQ2wchnSMyf1eHasePLNnFL1Y_seI
2026/09/09 18:13:49 Stored clip (50 frames, motion=46%, 4.3 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $GmQ_J-Cii6Gume71EGea3nMvR881IGJTJgaOw83jR3s
2026/09/09 18:14:00 Stored clip (51 frames, motion=40%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $dt6XKpmd5YivL7C7uVjZZsZjCtCOPZBAcYeTBpwMvgE
2026/09/09 18:14:00 Stored clip (51 frames, motion=40%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $Y8z6Uvk7EnFs0iXnIjco_ciYZrkj9XMjZrBhrJA0OH0
2026/09/09 18:14:08 Stored clip (50 frames, motion=100%, 4.4 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $tws8w2Y8D7WelXT0JFmiLBGybkHTe31DyRwPNeDyDoc
2026/09/09 18:14:08 Stored clip (50 frames, motion=100%, 4.4 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $IDRUKukcG15SZgVOqzJVubizUKt4z-liOxKGq4RzsUg
2026/09/09 18:14:22 Stored clip (49 frames, motion=98%, 4.3 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $sQRMEyZOtz-Lb_jD0PGqKDW6yYbebVBCQRzSDfd16rQ
2026/09/09 18:14:23 Stored clip (49 frames, motion=98%, 4.3 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $OwLmt5yIZ0l78Iah3RC0u1UxIjMfzB8g3JZQfZYPO7o
2026/09/09 18:14:34 Stored clip (50 frames, motion=79%, 4.3 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $qoEyGl9IBNawzakhwADF81YvdvTIrWWgFbZTlxXvCuI
2026/09/09 18:14:34 Stored clip (50 frames, motion=79%, 4.3 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $hirAq_QMmjyNkiaVCpKz4sLheYzdTn_ScvlAIQAbECg
2026/09/09 18:18:25 Exiting
2026/09/09 18:19:04 Matrix backend ready
2026/09/09 18:19:04 Listening on :8082
2026/09/09 18:19:04 decode error from 127.0.0.1:50938: auth handshake read: EOF
2026/09/09 18:19:19 Exiting
2026/09/09 18:21:34 Matrix backend ready
2026/09/09 18:21:34 Listening on :8082
2026/09/09 18:21:34 decode error from 127.0.0.1:49226: auth handshake read: EOF
2026/09/09 18:24:39 Stored clip (50 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $9d8vHWRh6i2IdfLGLUNBww9sHOnJjgYUU9SC3Po8WAE
2026/09/09 18:25:05 Exiting
2026/09/09 18:26:06 Matrix backend ready
2026/09/09 18:26:06 Listening on :8082
2026/09/09 18:26:06 decode error from 127.0.0.1:54458: auth handshake read: EOF
2026/09/09 18:32:14 Exiting
2026/09/09 18:32:16 Matrix backend ready
2026/09/09 18:32:16 Listening on :8082
2026/09/09 18:32:16 decode error from 127.0.0.1:34810: auth handshake read: EOF
2026/09/09 18:32:32 Stored clip (12 frames, motion=100%, 10.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $dnghP4kFUQnavnXLaj7KCMNeW7EBqDCZP3SPnni6s68
2026/09/09 18:32:32 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $OjXne3U4JXfkuBXGYo41QsQnlaQm3hhA8rQeVUiijc0
2026/09/09 18:58:11 Stored clip (51 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $HGt9Y3MqNqQJhfRgpc4UqvRs9K7ObWwIHXOU2S5gPEg
2026/09/09 18:58:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $vvw03198vht9CNjAU4dayO8v-G7AN8jZJzXTQlUr-S8
2026/09/09 18:59:03 Stored clip (49 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $938axXi8vpTE6HNXlFH9aw-hWgx2KxGmEVqxFq5E8FQ
2026/09/09 18:59:03 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $TVH4-cvmMeDSe80NPZSA8MLwBh54QljD-3HXqT8ftG0
2026/09/09 19:00:31 Stored clip (50 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $ydABm0e-bs2s2SXacKZ0UFovpq7ofZfnyp89Ke5vxpI
2026/09/09 19:00:31 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $LxtwdHzKyp6oatUaUCu84Ho7061BceU6s9r1kaIA1Ro
2026/09/09 19:01:11 Stored clip (51 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $bnT0_hy5aDXQi_Emf2Fh2CHz-VUF8GmeO_CqK4wHnkA
2026/09/09 19:01:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $xmpWDknvae1f4yBhPZpL1HlnECKl6ESjV5zm2H9DwDY
2026/09/09 19:01:21 Stored clip (49 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $9nj8EXNJXH0J6ePqZ81SYiKpO7k3XLAgEP88jmAJwT0
2026/09/09 19:01:21 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $6d7e41LovcuFVaynTUk0xjaZ7f1N3rmtzjn-m7Z_fSY
2026/09/09 19:01:42 Stored clip (50 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $4HT-Rkzw_sbQnk_Yqj18-TdQHABqkta0rj64WsLW1FM
2026/09/09 19:01:43 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $yJ-DZyw4vvcpD0CI7MFnWSX8sNEr6QoQFlt5oltMKWE
2026/09/09 19:08:21 Stored clip (51 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $oJUZqbEHX_HMG5rz5aAthqFmNYErDeVnSt1qMZvs9fU
2026/09/09 19:08:21 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $RQTsmqJSacjmQ7Ac8nbHkhcY1rkjO3tviayeMM4UTnw
2026/09/09 19:09:51 Stored clip (51 frames, motion=30%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $7cHBaY6tukrb9SjhYYcq80UV44-UKyGXa0LOIHdROvU
2026/09/09 19:09:51 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $nLgG_fOpzfv39YTL4MqMKrM0ZESs7ka91GrEUMKq3HY
2026/09/09 19:10:21 Stored clip (49 frames, motion=47%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $0BJzyin1-jKHIlNr_KY2GRRc2nW-4Euv9Zcs2WNJn3Y
2026/09/09 19:10:21 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $Q9RsoBJFRCSzy_usts0taT7PyCGidhYvQsWmQqFNKuk
2026/09/09 19:10:31 Stored clip (50 frames, motion=73%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $DWu8r6wGSsRkKXL5jqao0SEMEY5-5_KdIPvh7YEitcY
2026/09/09 19:10:31 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $-juFHY1zidQiBa2LKjdVjVwfHdxZGY1BKXa7TXmM3xk
2026/09/09 19:43:01 Stored clip (12 frames, motion=100%, 10.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $XQMbnP-DmsbWi7n-ywXNEGyozU9JvbFhvKcLmYeCPNs
2026/09/09 19:43:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $-uotcISg9MCeO4O9zKFSUiJ-aBHTEjx3ANLkhoX9oq0
2026/09/09 19:43:41 Stored clip (12 frames, motion=100%, 10.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $2AbTKLUOHnqnv7PLKmNIpzLdreaXh_AelmLx6SdMkqY
2026/09/09 19:43:41 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $oNsPlNrAsXrln3ozUpC7_dEGPe_NK1sBR1N7tbCluF0
2026/09/09 19:43:50 Stored clip (50 frames, motion=100%, 10.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $599F0FfKSUywz9htzLcxpFKv5AEBcBypcvtqiLpkyvk
2026/09/09 19:43:50 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $cMfasUPPicdsPJIhI1R7duH0GWv_u4VgHCDfsUZzbI4
2026/09/09 19:46:35 Stored clip (12 frames, motion=100%, 10.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $_jNYOto0Pz4wYG1iEHj1Nx91oHfJKf1Gueo7b_Mris0
2026/09/09 19:46:35 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $H-C67u3_WsVoAjsx23SfOfsOdwP6CSKPB5e6QwRuEvo
2026/09/09 20:04:51 Stored clip (51 frames, motion=41%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $u5cuIpXtxrpbcsvTyVA08irRRGcMmv8GiDcZE4aHxpQ
2026/09/09 20:04:51 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $oQB-PU9A1eFVUM2rByghU1yuLMeRjHkNqoRKX-aOpKE
2026/09/09 20:09:51 Stored clip (49 frames, motion=81%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $WAIom-HDHW_lzWjEYWhBjtbqu-5UyUoi449Zl81q9WU
2026/09/09 20:09:51 Stored clip (49 frames, motion=81%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $dS_2RV7apfhETW6utveQXk4xbcVOAXS5hLLW0v6-d-c
2026/09/09 20:09:51 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $cOb4weutdOf2MXgFb0F0cfnux3QbPIHLpozy20rdUts
2026/09/09 20:10:01 Stored clip (51 frames, motion=60%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $I3WGPYn4Qw5XFtqV3UrM2MCoOt0XUVl_IHkIcvvxWBk
2026/09/09 20:10:01 Stored clip (51 frames, motion=60%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $QyWVzhsi_bpcxpM70DnS7PJwzLPZG4yGSquVsrrSSiE
2026/09/09 20:10:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $aMWS_YCACUg1VufGhfb96kX_sGgeXsFan6pRSDbk8wg
2026/09/09 20:10:12 Stored clip (50 frames, motion=33%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $xrMTeruzu1JrEQRkGdTguoE_BAAkYr5Hni93z3ESXeE
2026/09/09 20:10:12 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $HYgz758j1bw2j7FpSZe85qanDm1sU12bEE0SveDz7mM
2026/09/09 20:10:21 Stored clip (50 frames, motion=11%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $pHvMFKXIY5R4RjhmOyuFI50Rlwkej7gzXJ1XQm_n8uk
2026/09/09 20:10:22 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $YZEf4QX5MCKiXmgLR6mG-B-McUVSFw4vu8JOBMapLkk
2026/09/09 20:10:31 Stored clip (50 frames, motion=12%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $33r6AApPD_s6nqfMCymYJZwMmEpDcvfmdgRgfyGwbEA
2026/09/09 20:10:31 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $2eRuLxVw0c20gN2CZQU7I50_AOJpGLaNIPQ6L1vzBXM
2026/09/09 20:10:42 Stored clip (50 frames, motion=15%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $xogZ1u-souejdILyUm_QVpfD-HD6BLfxUNetVG74KwU
2026/09/09 20:10:42 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $otCv1Eh1EjQjlmKQtIYDEdm3AwqJw3ZQ8d12a7SxpZo
2026/09/09 20:10:51 Stored clip (50 frames, motion=3%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $menuVsxQz33wOht6wkpNu-XTSHFI3YwtZ-vHnVnqqPk
2026/09/09 20:10:51 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $viPQdBxqABcM8cOWcyzftLGOW7iT74cu11K2LgkXYA0
2026/09/09 20:11:01 Stored clip (49 frames, motion=6%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $2ic371gEhV9Tue2WmBmblbvJWoQVX_mMiTiDuM6eSMQ
2026/09/09 20:11:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $AcQXvorQAVaEh3ccEasmP99AQX_lJS5SyrmRWLdxKmg
2026/09/09 20:11:11 Stored clip (50 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $9cnXitj7g3r7Wz0ualTs2vV1BrkryE6Nj64VTE2nl80
2026/09/09 20:11:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $4eRqs4ziEKEDjJWChgBvDphUl7pqkVZRIDZEEzDu8U0
2026/09/09 20:11:31 Stored clip (50 frames, motion=3%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $EcqV_LX_nUoB4XrSif7cI69cIg7ER3uuH65Xufiwxc4
2026/09/09 20:11:31 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $TVKJgiTVBShd2hYIj8RjCirxZG6tyiAMoEvrSqs9u_o
2026/09/09 20:11:41 Stored clip (50 frames, motion=6%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Faq6Q32Q5RVx2FB5RatQVgVK4nAXbBa8ozx6X_aosNw
2026/09/09 20:11:41 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $dQYZMb_iHVVw8yBgseZbx877OseA6jm2M30OpZCcNYk
2026/09/09 20:11:51 Stored clip (50 frames, motion=26%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $tX4w5L2IcUlPLd8JDmIoU3_CsbnoZ34Wu6xMYabK19E
2026/09/09 20:11:51 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $AoFbo8BfwAL6FhH6yZxFZ6H_9mQD862CEzU4IWiu9Z8
2026/09/09 20:12:01 Stored clip (50 frames, motion=28%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $b-2k0-iEznZ35WvdrzFiR_lr6vW-Ad5pzY6zoovORnw
2026/09/09 20:12:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $hI0g1Z_jqqjVcqKp73dKy8cOFdpIQryl2MOCHSYt8kg
2026/09/09 20:12:31 Stored clip (50 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $53lpw5jPpfbCUCFf9wTT0elz7NnODukmy8l73TMZ2f0
2026/09/09 20:12:31 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $RhSrQxl3lQs386yIkoyEqKsw2rfyXxE40FHqmsEY9lE
2026/09/09 20:12:41 Stored clip (49 frames, motion=4%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $ixJrbbEw4TCTjLbpRyCW4yNpjGYnHNpKg7XoVCrAjl8
2026/09/09 20:12:41 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $ml5N6EauPlxebXIvyM2MJNvV2lAhBcvNi1voktkE8sw
2026/09/09 20:12:51 Stored clip (51 frames, motion=20%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $IrHKGKU9Thg_eO0sCrMUjKG_5eVhUpORjuHgKKGWQGg
2026/09/09 20:12:51 Stored clip (51 frames, motion=20%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $JcAvooOZLy4v5fZnn0u-9lFwHVCc2vYLNOyWJ7x82vk
2026/09/09 20:12:51 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $4_Fj7kWoWw02IjEGFKAwnd3WdXRW1Zn6V_naJDmyRAA
2026/09/09 20:13:01 Stored clip (49 frames, motion=15%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $m0_6dzPD9H4NFDMibltsGjoCqYWWS4l0Hv5fvITMCbg
2026/09/09 20:13:01 Stored clip (49 frames, motion=15%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $usI-YyVx9cdAmgFAxUc_uA4rY6BPLP5VNBizeS4d1xU
2026/09/09 20:13:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $HielNk0sg8VN4Qt4_bp_w9jwnbqnCtisQKHye4OedEo
2026/09/09 20:13:11 Stored clip (51 frames, motion=18%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $hOU743xCqVJgKDeBUfgAXLze8Ur9mM6RFzR9TgkZLsU
2026/09/09 20:13:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $3hebYcKgucLkUJ9LuYr0idKnTHeh5dhWQzUn6EOuatg
2026/09/09 20:13:21 Stored clip (49 frames, motion=21%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $qvyko_86yenHbzIrvKcM-NKCL_X5UuX-SLotgfg_qAw
2026/09/09 20:13:21 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $yPgA4EB-pLdRWLVAE1jljyIprCytl64B66MvPNY6X4g
2026/09/09 20:13:31 Stored clip (51 frames, motion=16%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $DAlcJAKh6pO7r-dPTIWiRkAVwVvOV0DGVHUWD-cOkc0
2026/09/09 20:13:31 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $nHiERisntpVnsEEW0l-2K4eVjopLnb5zQ0ZLbaNf3LU
2026/09/09 20:13:41 Stored clip (49 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $xxqJavkdo_MtVUyIiD7RGkYH7tmvX9zTbHLZSrBXDNY
2026/09/09 20:13:41 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $nSiA7n5v0QVE2YqegxJC1nxEYu72EGTTnILsDuhT7oY
2026/09/09 20:14:01 Stored clip (50 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $iFGHBTAeJfPIBjE4nqr2vcNl8OwcMWIC1Yxjh5VcSnc
2026/09/09 20:14:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $G0lTDJiwHc4iw9v1khJwVvsuCIUcu_4lY8AuvbxiQgY
2026/09/09 20:14:10 Stored clip (50 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Jazt95Hgh6RIpN-jTNv3SzTRUKCpUFqEjWiQcDBUyRc
2026/09/09 20:14:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $n2ir5Amhwz_O7Bl5gMWYoZC6CKo9ZwcjemjvtnBkdhA
2026/09/09 20:14:31 Stored clip (49 frames, motion=7%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $uQtSepIumQhXYEbk5JJ4nePgiQoMCtVl5fTUFUssvU8
2026/09/09 20:14:31 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $6BeDToCX59S456fqDwgE2G25iW0MwXG8N7hudTzY9qg
2026/09/09 20:14:41 Stored clip (50 frames, motion=3%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $2RdHO3MeeLutI3ECcnNGHa7O841AhIRBRxg6kjZOPX0
2026/09/09 20:14:41 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $F0Uz0615RMy6yFkVjnH5iTD-2sB8uZH1JRo-JFwwc1Y
2026/09/09 20:14:51 Stored clip (51 frames, motion=22%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $gRHSZa9OpY9hGZFb30c0vnqutse6F5d8s5OXfZpBEoM
2026/09/09 20:14:51 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $VJj46hayPzq7JSNwNJ9yqeUff8_5t5on2f307gsfPIE
2026/09/09 20:15:01 Stored clip (49 frames, motion=15%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $egP68eQgiQ8xLRkiC9WFfDRhf4JYiYMgSvEtjSKm0-s
2026/09/09 20:15:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $ougqqv_k1eYRSwGT2NtoBY5WSOcb6mAKpuAJnSywE2k
2026/09/09 20:15:11 Stored clip (50 frames, motion=4%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $swjpdUPVvkSxjEUNywbNGwdPKpX1G8u5fEXcDhD3MJ4
2026/09/09 20:15:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $XYgQY7UFxYaZfciAfi0XkqoFwwID4PvC4xtCXSijDxU
2026/09/09 20:15:23 Stored clip (51 frames, motion=10%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $ZP1hnC6m_JZI9xx4Ah0zWCBRvY7QV2b6bXfVCmm-0xo
2026/09/09 20:15:23 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $RdywHGY8gR8A9dJd3U44xCpJj2m2dKTzDGQnXVO-XL0
2026/09/09 20:15:32 Stored clip (49 frames, motion=6%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $5fl4scyQMS33I3QA59-hDMw2dCPilnwdP3Ws5Mudkvg
2026/09/09 20:15:32 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $O_zTwiL2LCdc-iebAhaJ33aByUt7mOQYKLY9EDRvu2s
2026/09/09 20:15:41 Stored clip (50 frames, motion=26%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $ehMyXoCWEVDqjmgRCGF3WlM_8xFkNGMsUy0TGzVviZU
2026/09/09 20:15:41 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $GuAUB7vfqiECrYhkjCc4d1rdNRB-SZi-PtT0VL_D3lI
2026/09/09 20:15:51 Stored clip (51 frames, motion=24%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $PN6mbKCoZLZUp_DPjsI5CCv6jE1qRwRYB1ZvEnzgOBA
2026/09/09 20:15:51 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $5RB7LcvnH_ZKsVqb8xozVkW0bMUblLv1fWk0k9adLF0
2026/09/09 20:16:01 Stored clip (50 frames, motion=5%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $PdTljJMMZ5er7Fru4UL1A9qywx2f8W2afFwWcIeAKQ4
2026/09/09 20:16:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $OxP76bEgiQq0pQ8VjywBk7k8Ue_u7zHFRc_WM-vsBlw
2026/09/09 20:16:10 Stored clip (49 frames, motion=7%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $5PfoURfqFpcIHJ1ElHxH_pRifqnqjqA3pYCTZpqPAb8
2026/09/09 20:16:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $xYvH-UnTnooxXYx9__Dg8OT3HC-kBFXw2u_PC7I6Lg8
2026/09/09 20:16:20 Stored clip (51 frames, motion=8%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $R3eK7-j-eJTDKejqiAV0G9T4jmJpzjNRZA4BD7cmkyw
2026/09/09 20:16:21 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $ORVxKejnS-C_pDdGBKKqIc3vXGKUA4NiKSTjBkXO9DA
2026/09/09 20:16:52 Stored clip (50 frames, motion=3%, 4.9 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $7LiRfIYoc0YveZApIPkWC5mxO3nTEgw9Hr8iUAozhlQ
2026/09/09 20:16:52 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $KE85L4YKxGaLAjkxO553Ziy4_tJZIjkRq4OZA4n8Ga4
2026/09/09 20:17:01 Stored clip (50 frames, motion=5%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $ZUh3JdzkS8F-YMp07JFtMmF1rdNDe3P-wui4gQoBO3I
2026/09/09 20:17:02 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $vtkWEsGaRX5AFSUxterk4R6epYI1Y-lenk44MuotQ-8
2026/09/09 20:17:11 Stored clip (50 frames, motion=3%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $muWADUGz7SNmwGNwR_31vaBdLCqsjtL3tjJZ1iOU2-g
2026/09/09 20:17:11 Stored clip (50 frames, motion=3%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $bdsYmXNYYMwCkd55MqYWhqEJwbNhrYazs2oaoBrmp4w
2026/09/09 20:17:12 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $EYih1TnYPKZBHsUhpKFrTuEsbje5FVYT-vD3cVWCn7k
2026/09/09 20:17:21 Stored clip (49 frames, motion=26%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $ekpUNmwtSQcTNQ_KKad3iFJzY9L2jOJMkRtawci-sdc
2026/09/09 20:17:21 Stored clip (49 frames, motion=26%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $fK7QSFf2AtkpL9xnqfvc6KSkf0SqsplmgjGVrbGXV48
2026/09/09 20:17:21 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $E9gyWadn3OwUovfqsJ7nta1FbhGqjXpz8EDQ7aC1OIw
2026/09/09 20:17:31 Stored clip (51 frames, motion=29%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $q0NLP8duOCaGwyodPYTJrfGlDkUAnOulCT3MhU_0ioU
2026/09/09 20:17:31 Stored clip (51 frames, motion=29%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $IjWj7U_O2N3e042J4Whrfbrb7nvlTkbP05HeySLw1I0
2026/09/09 20:17:31 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $_9E9kk9VWr1O7noSdhJHe5nkek580fFmhe97Ex-cykg
2026/09/09 20:17:42 Stored clip (50 frames, motion=29%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $jIQrKcJ1Hqhpy2tmp--n4-3I33irn1_HJvSPnYYHWGI
2026/09/09 20:17:43 Stored clip (50 frames, motion=29%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $F_V4-r7KUnuFFpfL-IPQuHZX2AgnAehwzKuPpmCHXN8
2026/09/09 20:17:43 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $BpXhca0i8HTWp2M73UK9Glp_laMVFcAPDpFKCTTkdVk
2026/09/09 20:17:52 Stored clip (49 frames, motion=8%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $YmArftx9U9ap5ZI7186lm-vl6o09sj5qBuydUvKbiJE
2026/09/09 20:17:52 Stored clip (49 frames, motion=8%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $nunvKrSYj786gdWOdRt9tsjR7PldyCF6AsPiOZ1IouY
2026/09/09 20:17:52 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $nSu7vwkVe55DovHruDupTUWs9yVOvWczftuD36GchT4
2026/09/09 20:18:01 Stored clip (50 frames, motion=7%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Gt9Q5RAyaBOPWc2kSYC0PVTcllPskYiKz9vZblBO7r4
2026/09/09 20:18:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $VQ6Da4eYIQ9AfGgbYPsipteIAVIazX6VKkjIN3Ttl6E
2026/09/09 20:18:11 Stored clip (50 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Na0BFVvE0fJATFjqsBxqMmmB78-WwIkn8P3IEehODn4
2026/09/09 20:18:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $CGFzpw5UefanSsX2NC5vRB_ROnCwpbOPTVWJxLMuoDc
2026/09/09 20:18:21 Stored clip (50 frames, motion=7%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $PpClgFMb9MjvXizwc_AgeCg52eP1RPfZ6GjYuUrf0ys
2026/09/09 20:18:21 Stored clip (50 frames, motion=7%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $6P13Dkpu3bQPmFPhO6h9nyIUBiUaR8tY-Ekwl6ClLa0
2026/09/09 20:18:21 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $LdA67r7OE5YVc6vXae-MKtKalY1Is8UxYa0omkZ-Q3c
2026/09/09 20:18:31 Stored clip (50 frames, motion=29%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $wPyxmhWLuzVY5ncoAtwjLC4eEZrGGo1dJio4qWeH6q4
2026/09/09 20:18:31 Stored clip (50 frames, motion=29%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $RqRe2v483PW86zGZvQX85wwcXORdumQRTc2NvgSECGA
2026/09/09 20:18:31 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $oFXdJpifDiYNZc-8Qpnykxg4yvO1EHxYaYPbqiDjlH8
2026/09/09 20:18:42 Stored clip (50 frames, motion=15%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $zyFe-4DPyB-nNnP_3Yb1uK-Fwu-PKnpnmnb2bHK6kZg
2026/09/09 20:18:43 Stored clip (50 frames, motion=15%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $Z1yDeGSrSkBZ5mqnAhoDz9aFph4Zqz3JUakf1d-cHms
2026/09/09 20:18:43 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $-Kj93RppmKhVU8agzmZ8Cf-ln34SJ39c0gAmdEkMRYs
2026/09/09 20:18:52 Stored clip (51 frames, motion=23%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $jk2PBoGYCmcGzWPGjdwleHd9egOsytQin8bvU6Kb7ck
2026/09/09 20:18:52 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $eNOWeIiUr5u1vTwM-RmcrFitPcd1EK6Ntr--2pYC5iU
2026/09/09 20:19:01 Stored clip (49 frames, motion=25%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $KJYNYYBfAO1qaq3pjGr9Xrf4f04dNE60QOK6Hur0aQ8
2026/09/09 20:19:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $1iEviSEExXI6Byzl5GoRJ0DrLeohDWLYeT2tkSi7uFs
2026/09/09 20:19:11 Stored clip (50 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $6nX3I66u2WmNO-RYs2fKTYD5s3YPkqXvljZOTnMERxE
2026/09/09 20:19:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $p_NN_dO_8DfuX-I8wxSG7kLlB4skjxE-gxjmHiZqU4U
2026/09/09 20:19:33 Stored clip (50 frames, motion=4%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $6DSPd-vbtLweevD1QFHq3duv5FfLnLkQSeQwmuy6H7c
2026/09/09 20:19:33 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $CcWtcJqTgVwwrjbcZCKahIpINHg76clfWgmA2BKXmcc
2026/09/09 20:19:41 Stored clip (49 frames, motion=7%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Y7EWnumyOkJFlz3cBqttc4SUcrR7ZUYk-1afvH9dH4U
2026/09/09 20:19:42 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $YWBnN-9xcBf_zxBAsWJPtu17-PLRYDQ3IP0Nkg4DYjs
2026/09/09 20:19:53 Stored clip (50 frames, motion=22%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Dmg5bYC3rbTRkKJESiLuZBH97x4M6NnsYoX91_r-STA
2026/09/09 20:19:53 Stored clip (50 frames, motion=22%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $5OC9yajO-w-B-mIiDRQiTKqHX3ib-T4xs9IZ63FTA74
2026/09/09 20:19:53 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $SpPGegJihYTzZv8R2KsrY73uOIBwZUc8OxhFePS74Ao
2026/09/09 20:20:02 Stored clip (50 frames, motion=26%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $nT9nLzV8J0Kbu_pAmKTONwNkasCbvNcTC-6JWSw-oQg
2026/09/09 20:20:02 Stored clip (50 frames, motion=26%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $H56Re9-Q-LBYPqEH9d2ZJl_K_rxUcBM92WlQjxHrw00
2026/09/09 20:20:02 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $0Id4cdUobxmItDCBTpIz-7Y9VRu3hLCOBHVwBEUqGdE
2026/09/09 20:20:11 Stored clip (50 frames, motion=29%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $10yWO1uDkHlulpr0ZQO48_VyARg3U-h6ymWqh05CReM
2026/09/09 20:20:12 Stored clip (50 frames, motion=29%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $hBKgmC1rMuMZSp-Nh64tlEAqzXBiOAiQ7M3pTiagCGA
2026/09/09 20:20:12 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $R9C8oB9XVu_29CRz-wLmUXHOalhPzYo5EUilUehDA5Q
2026/09/09 20:20:41 Stored clip (50 frames, motion=3%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $eiknwp9BeYptaQQUNjb8NbK9xGPxaf8yrFqvP7rVLoU
2026/09/09 20:20:41 Stored clip (50 frames, motion=3%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $RnrHhN_clMnLvuSHvNxYFVx5bDWNIm-waZ7V-i6RknI
2026/09/09 20:20:41 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $hQxC1MUE4fjiXp5IrF3zrkjzZ_P257j1HgH64XlahYo
2026/09/09 20:20:52 Stored clip (51 frames, motion=5%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $be16kKH8S3x1Dd2kt6UwtpWULsJKMwC9GaywDB0_iL8
2026/09/09 20:20:53 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $CzLJ_IORTbFpsRj-gmu_NYN378WnGHO8951RKbRZUkw
2026/09/09 20:23:41 Stored clip (50 frames, motion=31%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $vO0cY5x5M7n1Hf-xPCM4raxAH_UqtgHI6GEzqGDYgcg
2026/09/09 20:23:42 Stored clip (50 frames, motion=31%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $lkLokYrqIR81CnpGMCWmOCgg17UGXQyRQwnUtgcu_cQ
2026/09/09 20:23:42 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $H1eu5q8dAY9uyI_y4clgX8hAN0xcfwioLprOkmkb9T4
2026/09/09 20:23:51 Stored clip (50 frames, motion=12%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $5mPHLikrbPuU2AIr9dDIqY_addpu_aZAb9AHBBnUen0
2026/09/09 20:23:51 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $3wSBfkq_yJpoFU8iw_1BvquB6x5uXe5iqrxpS5132zE
2026/09/09 20:24:01 Stored clip (49 frames, motion=4%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $UUfKDRwIaA3bo-tkZtEQojYmr9N-QcX-XJW-s9JmIaU
2026/09/09 20:24:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $5o4T3rnFP_ekbmGWqAmfZLFS214aum-7_sPWMKyboOI
2026/09/09 20:24:11 Stored clip (50 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $2fwcR_YLYsbkVijtb4fbQU0_b90onFAKvwOb5znzNug
2026/09/09 20:24:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $RZ1bIoCw5ncic_HhrpKg1d-OM2285GBlGM83YgVHUQU
2026/09/09 20:24:21 Stored clip (50 frames, motion=7%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $tkLjHsZNqC89ozv4IH4rbrFz0xhOxj9msQpAjibidvA
2026/09/09 20:24:21 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $A8jMRsH2QzMudt9Vz0HtEtVasZ448H9ozzRZGWolBGg
2026/09/09 20:24:31 Stored clip (50 frames, motion=3%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $CZ9a0Pk0t45dOZEjyEISFqCuAkTbEPJ_Y5R1Oe5fltM
2026/09/09 20:24:31 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $lz91d_gdcYpz5H5dp6dKkN_F6y61aVDzR09VO6Pi3RM
2026/09/09 20:24:41 Stored clip (51 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $uHsxgDJ5vUVPem_k3M8YtuC27CALUZM815fRa211aO8
2026/09/09 20:24:41 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $ZaPjMy1CL5eXcWdoP1lJNdEHa1avgqgU1S3f7GXq96I
2026/09/09 20:24:51 Stored clip (49 frames, motion=6%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $SokIqBM_yOZbESyWWCFGasJsI381_forYtXv-YKDNRY
2026/09/09 20:24:51 Stored clip (49 frames, motion=6%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $TQIuZFzc2nN7iUndz6J1KjYze0_Ncd4V03KcJqca_jU
2026/09/09 20:24:51 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $Vj7ouT9N43LTWL_Cu_W3WvAO96x97VdLurMNhoBzXU8
2026/09/09 20:25:01 Stored clip (50 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $S92Q5iXvRIJoLlVFejgeFFhWFlrWdwYUpYzlMCkLN3U
2026/09/09 20:25:01 Stored clip (50 frames, motion=1%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $B9XSB5nahirp7kDzw-CIxWLgukrDBE5vjq93K8G1RDo
2026/09/09 20:25:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $euLFzetS6-Pis-L7AbuKMo89ga8Eh8MVF_tV1fP5xXU
2026/09/09 20:25:11 Stored clip (51 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $ttA834J8tzTKu4WdPgtxbitxe9Db4FUYRdmxsnbWbRI
2026/09/09 20:25:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $ug4UKg1c-w-xNuUiM0tRQFUalZE7D_HY10F8Z5TJWwU
2026/09/09 20:25:25 Stored clip (50 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $OuQjpArWaMZo6grkejweFYiQZF6j_D8Ps7yRPquIjN4
2026/09/09 20:25:25 Stored clip (50 frames, motion=1%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $RnbRNRQxbKMaVxPtID9OghxOWjlirnnVfKLz3_kYlW8
2026/09/09 20:25:25 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $_Ho3Kl3IZMmZaiiLxVPWbzV7AiAniPAPB-HIFSOTcTc
2026/09/09 20:25:35 Stored clip (49 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $de1Bpcs585klYvZ0c--QDeM7EDYu6xdTbn0fsl7sVDM
2026/09/09 20:25:35 Stored clip (49 frames, motion=1%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $vT-e0VHE2PPA5QS6pUYXFLOhGrbd_k-_xreUsYJIDAw
2026/09/09 20:25:35 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $KoWvUIWx_afk-ElO_Wz8UIv483XQdfk8WYlPOMJFnRQ
2026/09/09 20:25:42 Stored clip (50 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Z7tVIkzPbO4EGIC1HtGpliWHwsVyD98T9iSIAdmnFfs
2026/09/09 20:25:43 Stored clip (50 frames, motion=2%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $xaiaJ78Qs5cglbwzk21Nc68tHhfDIZOIHxKjp1-cHa8
2026/09/09 20:25:43 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $lN_bZFZKcdrh3W7jj82RAk-fZkPF5qyN_ff6x2Bw1jQ
2026/09/09 20:25:51 Stored clip (51 frames, motion=12%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $jG_r3L1_DVdDqjj3BKE1GwwE6TVrg9PnVunrC6uPk8Q
2026/09/09 20:25:51 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $bKH4oYCphsj9OwJKLqLHkNZsAjg0AJ22CW_ooZmrkSA
2026/09/09 20:26:01 Stored clip (50 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $tXAxpyv-wrQo787oUAaN4MxyQEUcIY8UaQQnzi_kEj4
2026/09/09 20:26:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $onj4nx3E4c0ZlCefU2CshXawOBtLgwLSPKmPs0X1nOo
2026/09/09 20:26:11 Stored clip (49 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $tAI3LI7Da2uWrsCRA2Trx_QVsa7Lv4HVjM-xKzr2_Y4
2026/09/09 20:26:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $pXCEcykwyvVS5MF1_I6aQKOR2ajsYEZWF-wi7lV0tYc
2026/09/09 20:26:21 Stored clip (50 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $3TfecGPZcE-DpL2vbrGGwlyHhOE2NQ8oVF64kc2ge1s
2026/09/09 20:26:21 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $BYi4bdi3seGNF-XmwZgY20F0K02mQ0oApv7CmI-NTbo
2026/09/09 20:26:41 Stored clip (49 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $qh0SDWGaBD5a9ZQZ98DIQqXdhi7oQz-48Zl6-Ki9KiI
2026/09/09 20:26:41 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $E_tr-UeulgTLyqzowXMHTyC63HE0Qi6CK4LQF152Cyo
2026/09/09 20:27:01 Stored clip (49 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $NCh_lPNcpWsgN1E0FrzcW1dgRAMOBmGZevCEEkz2cN0
2026/09/09 20:27:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $E1W0khL3S286yMTz4zCdvBlOELjGgO7AGbWg41urb_U
2026/09/09 20:27:11 Stored clip (51 frames, motion=28%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Yw3boz1AUqavZOZcqVWlbetZ5jENtQJApm4JMGQT_4k
2026/09/09 20:27:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $MB-XGcUbiwTeX7FWRPZJBB5frmxuMZg6wMIyUonimrg
2026/09/09 20:27:21 Stored clip (50 frames, motion=20%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $5NPcPQXVtikFe7Dlb8LDsRya0yFOvF_UBoy5sShre1A
2026/09/09 20:27:21 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $M0GZx8O6GvDhkK0_WXxKBvcHseVYEhk1cAl-Au-3CmI
2026/09/09 20:33:31 Stored clip (50 frames, motion=56%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $SWrQZZTKFCvg1Fgo_Bu6UKFPZu-WRMjm7X99W_6KnWM
2026/09/09 20:33:31 Stored clip (50 frames, motion=56%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $ZE3uA2VM752jZQSOUsj3tVlyKcft5aZnbf58jLSDUNQ
2026/09/09 20:33:31 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $wpBlPN4zf5wFxRQ5YSmOa5J9vlt55I1wG5C7Js6yihY
2026/09/09 20:33:41 Stored clip (50 frames, motion=59%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $TM-ASgtRuYxoXaCnWtyciKEfQ452kbWIyjcWi5var5Q
2026/09/09 20:33:41 Stored clip (50 frames, motion=59%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $rUMreEA06j-T6ZqnrUofjNACkFgb4Ph2EQpfCn__rK4
2026/09/09 20:33:41 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $Jq4PTDURYW3Gqw8HXbkLc7xPxZnRniFrte8gQJUUk-c
2026/09/09 20:33:52 Stored clip (51 frames, motion=25%, 4.9 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $1pX0rKO44181pjCB25vZv__tgOw94qVTx3Tg68Hs-EM
2026/09/09 20:33:52 Stored clip (51 frames, motion=25%, 4.9 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $2vO_Dhib_X-dvMQqH8sqk_1ijq7cVrByVAp2IPIu-TM
2026/09/09 20:33:52 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $3nT3MwfN5xsOMn9WNGUyRUh9NgegiU_fXZQcFqi1E3g
2026/09/09 20:34:02 Stored clip (49 frames, motion=15%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $SRd6QoygBvoAYz2TcN1xyzLV4ZOtW_ORT2mVUcxPvYE
2026/09/09 20:34:02 Stored clip (49 frames, motion=15%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $ZuCBHR1-TUSyPyCe2dRtCAIVLDSuJpLRxR5j3qmZxso
2026/09/09 20:34:02 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $cve5htPqGO4_DU2iwb19kfD1_v2mwB1_hHfDbP2mT-g
2026/09/09 20:34:11 Stored clip (50 frames, motion=21%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $yj3W4p1pCC31ggrFOdJ2xiJmKO5l5FG8NWF0LiwN73Q
2026/09/09 20:34:11 Stored clip (50 frames, motion=21%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $6Lrx1oqsWCAiN7i5YvrM7NciBa4rTc4UAJujjF-Alag
2026/09/09 20:34:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $Uk0kFCrYG62x0uO2EgundcvgjK1LgvVyxCbBUJLYb8Q
2026/09/09 20:34:41 Stored clip (51 frames, motion=27%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $cE7rdL4Xk-SCOs1vBgN4RbvY3pPcNI3a6WXiflcH9jU
2026/09/09 20:34:41 Stored clip (51 frames, motion=27%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $wWjki5Y-bDjOvi9TMXvQc1eiowdYDrpEmZ3n6FkFDhI
2026/09/09 20:34:41 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $-20szWGKM3TisZZ1foIFdAp7u0ZZO2ItRdhYeaTpXYg
2026/09/09 20:34:51 Stored clip (49 frames, motion=68%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $UC8fqfkPYoeeEsoBvF30vrWDf5JznrhP9-G-9KgjTb4
2026/09/09 20:34:51 Stored clip (49 frames, motion=68%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $zGY-vJ7LZ9m0SFiewLBi5x72U98mAf8lze3GnHNLZuU
2026/09/09 20:34:51 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $nazey1lQ5TTq4u9614qWWzarUPC1rK-31o2wtTtCfvc
2026/09/09 20:35:01 Stored clip (50 frames, motion=8%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $556aulvLD5M0c7fW-x02l9M3RyQQYPVGwwWgJpIEkqQ
2026/09/09 20:35:01 Stored clip (50 frames, motion=8%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $dyVQ6-b5CUHJxobfTeAHvhWJ0mZ2kcnT8NP09TOitNA
2026/09/09 20:35:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $HBqxMawvKAoOkFpGooUiRYod0NZ3KB67VAIUx4EQ9XA
2026/09/09 20:35:12 Stored clip (50 frames, motion=42%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $hHBMHgKsThUX31yYbfC6dmcZjquzdYWKGnM3g0HxuY4
2026/09/09 20:35:13 Stored clip (50 frames, motion=42%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $jDzZmMRkpuzrq7BpwcR0AeLpTndS-6NVLcr3Z16mJxc
2026/09/09 20:35:13 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $GnePOh5lgS2-qXXoN5ekst9ZFcPsC7a489fjM4VMfVc
2026/09/09 20:35:22 Stored clip (51 frames, motion=44%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Uu0tVqiITVr_NG6R1dH6Z6yzsAhH_1qPdB97nwuFb2o
2026/09/09 20:35:22 Stored clip (51 frames, motion=44%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $_dDiCnpxzlO2md7-4dr9Kw5N1igMpcQKeVmxKIAgGTQ
2026/09/09 20:35:23 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $WFXVAb7mHbIPWy0U9hJNAe2tA1-6TjQmbVoKVdvkg8g
2026/09/09 20:35:32 Stored clip (50 frames, motion=11%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $-w02_KdW1X9pbRQWzhvd-ULOMQGx7QveBT9FFz2XB1o
2026/09/09 20:35:32 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $YyzKoLwFG3yqyhiKjln_mJgzGCfHo5HAGOaXmS4wh_o
2026/09/09 20:35:41 Stored clip (49 frames, motion=53%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $z0UZAjothV7aW7X8lVR20k5mQAvzc-hW6Dztyt9RL5M
2026/09/09 20:35:42 Stored clip (49 frames, motion=53%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $yjERbz_edyQAx61ADJ8lps7Lvr2YRKog4TriN4QS8pE
2026/09/09 20:35:42 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $5lF7R-HAMXyiQs3a1cTx6mNPMo1EP2GPOK_3d8iK_Kw
2026/09/09 20:35:51 Stored clip (51 frames, motion=36%, 4.9 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $_5NDVWGSkggxt7gmTGzxiVMfxELm19R2IEFrcUngso4
2026/09/09 20:35:52 Stored clip (51 frames, motion=36%, 4.9 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $imDm88A_by69CWYFon_H1quluJ6VcvnS0Fua2sfoXQ8
2026/09/09 20:35:52 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $4ctYmQqEAE2z0xgtATO6zynRT9wgMgzeR5SZrmY33ww
2026/09/09 20:36:01 Stored clip (49 frames, motion=26%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $8YppxxvMqCcZJtDUYwxa3iHEgG6PNLu_NcebEiDNt1g
2026/09/09 20:36:01 Stored clip (49 frames, motion=26%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $ICoUN_GinkTWMfKObZFh2TnzrUgnidO1ojfNfcHAM3E
2026/09/09 20:36:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $JGM3bC8wjtGDEdzZYloKrbKhjCwD06A1PGw0rqRiZR0
2026/09/09 20:36:11 Stored clip (50 frames, motion=37%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $0bFvb-qJiDr9ZSdip4bpsAPiboJ1xDqBWm3VAAn9fd8
2026/09/09 20:36:11 Stored clip (50 frames, motion=37%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $1-A6YoBH4Drq6FF9n68IN_8jrY9xlkVXKLjD0l9lPUk
2026/09/09 20:36:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $pM1GRwEmEplHt_RvcXmuZT4AkJmQ2rmsdvqTy3rZXGY
2026/09/09 20:36:21 Stored clip (50 frames, motion=33%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $7OMkES9-ZG7ii5xF1fk0c5DsXA1ZTGNf5NH-F3TBg5M
2026/09/09 20:36:21 Stored clip (50 frames, motion=33%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $eqpjGTJO8gKTv0Z4ljCgRq9D9d-SIrcTbLnSvQOeJ_Y
2026/09/09 20:36:21 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $g4tRBajeRT-Cc8kFL91YwRPzh_jXy_V4pVIUKzBA_Ms
2026/09/09 20:36:32 Stored clip (50 frames, motion=25%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $pZNHzK_q4RhjdJVgoGeA9Ktj_K6_1pGwCLMQ64y-_38
2026/09/09 20:36:32 Stored clip (50 frames, motion=25%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $ojStPpFg6JoNUwd1Bl-vIMtaI5QOkY14OHMGtG4bTz4
2026/09/09 20:36:32 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $UQdLngxynnS6YCh_iXtz7bWrtlsdNOk9gp9Ji5lrjBQ
2026/09/09 20:36:42 Stored clip (50 frames, motion=15%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $LACGhJ9NBVj325TF7xMnxRC3nCkbUnX7jpNh6bACWIY
2026/09/09 20:36:42 Stored clip (50 frames, motion=15%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $JTLMx9CkhxGQwS2ZexrHH86NMEsYlOltW83FOybgXYI
2026/09/09 20:36:42 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $vaXtnnuiJvQ0LLJ3mIG4KsPWd58yhwr7yI3Jrusf1yk
2026/09/09 20:36:52 Stored clip (50 frames, motion=17%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $aVN2Qo7BU5Xd0h1Lm9FwbXD6PQcta-Jco9tGKHEpCUw
2026/09/09 20:36:53 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $Xf3iuzNSNnQthffrHHM8G6CieNJUk_aRyV2q6kRgxcQ
2026/09/09 20:37:02 Stored clip (50 frames, motion=20%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $SKq8NM3_fJtXgWjbs2X9cIPxtNs2VKyiHh2Xyu4y4CM
2026/09/09 20:37:02 Stored clip (50 frames, motion=20%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $_Su0059RmlxAGmcLrMeJqC5xqi7Dm_s-sVg-3P7d3Tg
2026/09/09 20:37:02 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $YXE9KmOpSOGeB3W1BDVRNaDR731GHWobK1jP-ayzeoI
2026/09/09 20:37:11 Stored clip (50 frames, motion=2%, 4.9 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $cueD8szL3Gls6zhkbBiB2hTN6bDNCjmH3thHAe2scoQ
2026/09/09 20:37:12 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $VBZB7r3yk52uhXz0v36lK9Y8ijNnm0rZysuYx-wyvZI
2026/09/09 20:37:21 Stored clip (50 frames, motion=11%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $drh2CZBVZFkzllnB0egnqvCmk4v9x7IFjxDb8bnrhhM
2026/09/09 20:37:22 Stored clip (50 frames, motion=11%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $8vui4pfpk5g9KrIhKNU8hS3MFi14flQ2HOCTLqE4btI
2026/09/09 20:37:22 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $MbkexDGRiTqwu3WABte5DGhIW4E7WuY5GD_7ltG0GIE
2026/09/09 20:37:31 Stored clip (50 frames, motion=21%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $UAYFV5pkN4tWEQFOFy7WkQY1pxitLYCvEgzH7LI_K4I
2026/09/09 20:37:31 Stored clip (50 frames, motion=21%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $zsSIxfxp4m5PCpfLcp8RNl57AhpEjVo_UjeiTY6nUG4
2026/09/09 20:37:31 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $V_XadwqTSi1vvCYcNUoXRX2oCe5VUczq9c483xbYWL4
2026/09/09 20:37:41 Stored clip (50 frames, motion=30%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $ScIldZpySFrQmUM5gJeCz3aFjTCNUMWHNZQyY1DbMnA
2026/09/09 20:37:41 Stored clip (50 frames, motion=30%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $VZKV5KHdEkz7Fw4bc9kcPbGSzlKHLndYtYCPYT8AWMs
2026/09/09 20:37:41 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $vXKJOtSTD9B6vPGqk2kxSYehXYBDQKTcd9zhcMi7Isw
2026/09/09 20:37:51 Stored clip (51 frames, motion=3%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $gaLu8OgwhgLOI2n6mQz3q5m8lE2KJisapvfHGj9TUIE
2026/09/09 20:37:51 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $wYIVH3th0zH6iwFEcUCiwLsc9pa39PrQBsJjXwBGfHU
2026/09/09 20:38:01 Stored clip (50 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $HS1n2H0lfdVUJit0es17mjOQqqC23nuYCm6UrJpRHbo
2026/09/09 20:38:01 Stored clip (50 frames, motion=2%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $zinSNfWFav5_bNQE6KAPYWVLqzu742D2By8IkOE1NXU
2026/09/09 20:38:01 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $XeV9bzG07fY1cSHdGbpmfvxbGVdKT58Rujf7YNm3eUQ
2026/09/09 20:38:11 Stored clip (50 frames, motion=4%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $HX8ZYsqjNdsCRrVky4H_TqKU9MjnbyIPJcbJOiHkJy8
2026/09/09 20:38:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $cNqi6lnyTj3CzgHJAIpTCA88Vo4QZeSIa4vfJOsUMQk
2026/09/09 20:38:21 Stored clip (50 frames, motion=4%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $tqSd9Ex7WOAQQyzmtOjU-FZXoWPN-5FvJkmkbw5m704
2026/09/09 20:38:21 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $E7Ms40XNDO_AEFwv72yHGJDmjez-BtNxwNVeOqMSnCk
2026/09/09 20:38:31 Stored clip (50 frames, motion=3%, 4.9 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $DRR-Fs4ceSvtWlhovuJFdwMdOciFbvyVUecgmzGI9L8
2026/09/09 20:38:31 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $I9pA2TwnF7_kTtewSj8Xtx44DqZ2GjchjcqXxlAeWlE
2026/09/09 20:38:42 Stored clip (50 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $Yd-bPp4KSS-zkKNB-l51zCHEeDfi5Aoc7tx22H8nQPk
2026/09/09 20:38:42 Stored clip (50 frames, motion=2%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $WeTdVRrT7fXmCc8tXirW8i17DBJAhohCPji3ZJbCOJA
2026/09/09 20:38:42 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $CnXSxl8jd_TNDTD2UywmfiW7FB5aPorYkkgNw67jS5c
2026/09/09 20:39:01 Stored clip (50 frames, motion=5%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $BaxeNf6cYCq7QC8nn2Ebwa4Qtj41Kn0q1tKjGQOGm1I
2026/09/09 20:39:02 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $2J9ThQOKxZxbG0RrfdciXoZG_ipvt1fYFmhrYWkfsaw
2026/09/09 20:39:11 Stored clip (50 frames, motion=7%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $RN40gG1rG_mo7bA-Hgm-m0K1fPc9Vv9SNyPRuiNgoQc
2026/09/09 20:39:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $lU9oHhUNyKATY2MGMwqgjoZNNytoeXzUCkHzm7bTeQ8
2026/09/09 20:39:31 Stored clip (49 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $_XhflgGL5894Cl5yTOsl8ZOSYp9R6q5CPxYcGzppSRE
2026/09/09 20:39:31 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $2MyIXnxvZWom4XHCboMcb8ezrL7IgpzOyiORXSPv3Gc
2026/09/09 20:39:41 Stored clip (51 frames, motion=7%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $yTtFN_BpWgUSYUFwsYM2_7GclkFQf2TeWNL8scSsxzQ
2026/09/09 20:39:41 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $TPQmEqvHWr1eTs5eVklADU9CVVT66ywFB2Lp7QoOnpQ
2026/09/09 20:39:51 Stored clip (49 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $vTQlVdKtCTGt90NeGiuutHvM-nPgq7vzVoGbvTKQ9iE
2026/09/09 20:39:51 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $hg57uFeUPwRGVDRTY4fSsWuwcFAZ8ePfWMLkw7tu2pc
2026/09/09 20:40:32 Stored clip (50 frames, motion=2%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $_-JgwEMcHcOIDm2XFPj-t2xQ1WdUj4b20RHN7qX6WgE
2026/09/09 20:40:33 Stored clip (50 frames, motion=2%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $RJ_4fcxkbXz4zGepg-n9WeAAFf-kp2KPVpsDZcvdM6c
2026/09/09 20:40:33 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $Odv4mld3ElNOefwye4ieqBgpQ779_n7Yebb8BP7zGaE
2026/09/09 20:40:42 Stored clip (50 frames, motion=6%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $68-aIPxZHBz-D2zGG2ntAvffdp6X-n9owd1uM72Insw
2026/09/09 20:40:42 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $zvL6ZvMYW0ivy_SJhioMo3v75xt8cpi4jH8QgQN1POk
2026/09/09 20:40:52 Stored clip (50 frames, motion=47%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $NY9GeGZpq3HNkxQf74vEDZrn9FWi5zYjrXE7REuTBRE
2026/09/09 20:40:53 Stored clip (50 frames, motion=47%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $AD6mJ4hNhSXLq48pyeoUl5nd60EaxEFY1YcX8eeQZhI
2026/09/09 20:40:53 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $mADcHe7M4cOwLG5T3-yAReWedOvhfE9KgUX-wJbXTRw
2026/09/09 20:41:11 Stored clip (51 frames, motion=82%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $f4RW7v7ayZkQahbRJPQTAtxMm2zH1ouaPeXfnxBN2IA
2026/09/09 20:41:11 Stored clip (51 frames, motion=82%, 5.0 fps) in !XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ as $k3gpg_rjiOjHVnyeWvXbPq4fxNAqxpiw3oW6qwQWI_c
2026/09/09 20:41:12 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $TQ1G5Ot0iERanb3xllV1S83cUKffKDba712JRJDKREY
2026/09/09 20:50:10 Stored clip (49 frames, motion=1%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $TkA66nDaP8xID3XICnkfF51DgwjiQRlHwCsCjw7YfkU
2026/09/09 20:50:11 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $088Lnrt1InEv8IPQAF6vbBIh3PsHLH-wWsSUbcR1eJY
2026/09/09 20:56:12 Exiting
2026/09/09 20:56:32 Matrix backend ready
2026/09/09 20:56:32 Listening on :8082
2026/09/09 20:56:32 decode error from 127.0.0.1:43818: auth handshake read: EOF
2026/09/09 20:58:45 Stored clip (12 frames, motion=100%, 10.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $lp_2Bk52qE1Z6CvNlwX1TyUEj6fBH1eRtuYgzBJNYS4
2026/09/09 20:58:46 Stored clip (12 frames, motion=100%, 10.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $I9fE4GNy75yEUaYIFnP6jLeanxl5H4p_zeqC6Vtf4EE
2026/09/09 20:58:46 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $2R7r26JeKjSIrYarPQ_OFcqT1NaDJw-o0JHFVNtGo9g
2026/09/09 20:58:46 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $9cGs-grRZQWND_RFur9eWIYNQecf9uyCGa3qDTvnFUA
2026/09/09 21:29:58 Stored clip (51 frames, motion=85%, 5.0 fps) in !0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg as $7yc1tOv2Ufhc6RXrll15PiRgodNfkBMPr4fE5xOEniE
2026/09/09 21:29:58 Stored clip (view, minimal) in !wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE as $xyYH5Nlal7Onv-hKYtQqKA7OvFGvY5DtZfvbv-dhtBw
+1668
View File
File diff suppressed because it is too large Load Diff
+62827
View File
File diff suppressed because it is too large Load Diff
+289
View File
@@ -0,0 +1,289 @@
2026/09/09 16:55:21 Loaded 2 classifier(s)
2026/09/09 16:55:21 Listening on :8081
2026/09/09 16:55:21 decode error from 127.0.0.1:33864: auth handshake read: EOF
2026/09/09 16:55:22 decode error from 127.0.0.1:33866: auth handshake read: EOF
2026/09/09 16:55:22 decode error from 127.0.0.1:33880: auth handshake read: EOF
2026/09/09 16:56:17 Shutting down...
2026/09/09 16:56:17 Exiting
2026/09/09 16:56:33 Loaded 2 classifier(s)
2026/09/09 16:56:33 Listening on :8081
2026/09/09 16:56:33 decode error from 127.0.0.1:58400: auth handshake read: EOF
2026/09/09 16:56:34 decode error from 127.0.0.1:58416: auth handshake read: EOF
2026/09/09 16:56:39 Forwarding clip with motion=100% (detections: false)
2026/09/09 16:56:39 Forwarding clip with motion=100% (detections: false)
2026/09/09 16:57:13 Shutting down...
2026/09/09 16:57:13 Exiting
2026/09/09 17:01:10 Loaded 2 classifier(s)
2026/09/09 17:01:10 Listening on :8081
2026/09/09 17:01:10 decode error from 127.0.0.1:57278: auth handshake read: EOF
2026/09/09 17:01:10 decode error from 127.0.0.1:35872: auth handshake read: EOF
2026/09/09 17:01:33 Forwarding clip with motion=100% (detections: false)
2026/09/09 17:01:33 Forwarding clip with motion=100% (detections: false)
2026/09/09 17:08:12 decode error from 127.0.0.1:38314: auth handshake read: EOF
2026/09/09 17:08:26 Shutting down...
2026/09/09 17:08:26 Exiting
2026/09/09 17:08:28 Loaded 2 classifier(s)
2026/09/09 17:08:28 Listening on :8081
2026/09/09 17:08:28 decode error from 127.0.0.1:43708: auth handshake read: EOF
2026/09/09 17:08:29 decode error from 127.0.0.1:43710: auth handshake read: EOF
2026/09/09 17:10:11 Shutting down...
2026/09/09 17:10:11 Exiting
2026/09/09 17:12:27 Loaded 2 classifier(s)
2026/09/09 17:12:27 Listening on :8081
2026/09/09 17:12:28 decode error from 127.0.0.1:60042: auth handshake read: EOF
2026/09/09 17:12:28 decode error from 127.0.0.1:60052: auth handshake read: EOF
2026/09/09 17:17:32 Forwarding clip with motion=23% (detections: true)
2026/09/09 17:17:43 Forwarding clip with motion=73% (detections: true)
2026/09/09 17:17:52 Forwarding clip with motion=97% (detections: true)
2026/09/09 17:18:02 Forwarding clip with motion=74% (detections: true)
2026/09/09 17:18:12 Forwarding clip with motion=43% (detections: true)
2026/09/09 17:18:23 Forwarding clip with motion=32% (detections: true)
2026/09/09 17:18:32 Forwarding clip with motion=35% (detections: true)
2026/09/09 17:18:43 Forwarding clip with motion=10% (detections: true)
2026/09/09 17:18:53 Forwarding clip with motion=15% (detections: true)
2026/09/09 17:19:02 Forwarding clip with motion=3% (detections: true)
2026/09/09 17:19:12 Forwarding clip with motion=40% (detections: true)
2026/09/09 17:19:22 Forwarding clip with motion=50% (detections: true)
2026/09/09 17:20:42 Forwarding clip with motion=64% (detections: true)
2026/09/09 17:20:53 Forwarding clip with motion=18% (detections: true)
2026/09/09 17:21:03 Forwarding clip with motion=34% (detections: true)
2026/09/09 17:21:12 Forwarding clip with motion=11% (detections: false)
2026/09/09 17:21:22 Forwarding clip with motion=12% (detections: false)
2026/09/09 17:21:32 Forwarding clip with motion=20% (detections: true)
2026/09/09 17:21:42 Forwarding clip with motion=15% (detections: true)
2026/09/09 17:21:52 Forwarding clip with motion=49% (detections: true)
2026/09/09 17:25:51 Forwarding clip with motion=26% (detections: true)
2026/09/09 17:26:02 Forwarding clip with motion=48% (detections: true)
2026/09/09 17:26:12 Forwarding clip with motion=36% (detections: true)
2026/09/09 17:26:23 Forwarding clip with motion=21% (detections: true)
2026/09/09 17:26:33 Forwarding clip with motion=27% (detections: true)
2026/09/09 17:26:43 Forwarding clip with motion=53% (detections: true)
2026/09/09 17:26:52 Forwarding clip with motion=37% (detections: true)
2026/09/09 17:27:02 Forwarding clip with motion=52% (detections: false)
2026/09/09 17:28:00 Shutting down...
2026/09/09 17:28:00 Exiting
2026/09/09 17:28:02 Loaded 2 classifier(s)
2026/09/09 17:28:02 Listening on :8081
2026/09/09 17:28:02 decode error from 127.0.0.1:33438: auth handshake read: EOF
2026/09/09 17:28:03 decode error from 127.0.0.1:33444: auth handshake read: EOF
2026/09/09 17:29:16 Forwarding clip with motion=10% (detections: true)
2026/09/09 17:29:26 Forwarding clip with motion=39% (detections: true)
2026/09/09 17:35:25 Forwarding clip with motion=1% (detections: false)
2026/09/09 17:35:36 Forwarding clip with motion=61% (detections: true)
2026/09/09 17:35:46 Forwarding clip with motion=65% (detections: true)
2026/09/09 17:35:56 Forwarding clip with motion=6% (detections: true)
2026/09/09 17:36:06 Forwarding clip with motion=43% (detections: true)
2026/09/09 17:39:23 decode error from 127.0.0.1:43816: auth handshake read: EOF
2026/09/09 17:39:23 decode error from 127.0.0.1:43830: auth handshake read: EOF
2026/09/09 17:39:38 Forwarding clip with motion=100% (detections: false)
2026/09/09 17:42:17 Shutting down...
2026/09/09 17:42:17 Exiting
2026/09/09 17:43:07 Loaded 2 classifier(s)
2026/09/09 17:43:07 Listening on :8081
2026/09/09 17:43:07 decode error from 127.0.0.1:49112: auth handshake read: EOF
2026/09/09 17:43:21 Forwarding clip with motion=9% (detections: true)
2026/09/09 17:43:31 Forwarding clip with motion=20% (detections: true)
2026/09/09 17:43:41 Forwarding clip with motion=11% (detections: true)
2026/09/09 17:43:51 Forwarding clip with motion=5% (detections: true)
2026/09/09 17:44:01 Forwarding clip with motion=4% (detections: true)
2026/09/09 17:44:11 Forwarding clip with motion=47% (detections: true)
2026/09/09 17:45:38 Forwarding clip with motion=100% (detections: false)
2026/09/09 17:47:23 Shutting down...
2026/09/09 17:47:23 Exiting
2026/09/09 17:47:32 Loaded 2 classifier(s)
2026/09/09 17:47:32 Listening on :8081
2026/09/09 17:47:32 decode error from 127.0.0.1:59016: auth handshake read: EOF
2026/09/09 17:47:46 Forwarding clip with motion=27% (detections: true)
2026/09/09 17:47:56 Forwarding clip with motion=88% (detections: true)
2026/09/09 17:48:25 Forwarding clip with motion=1% (detections: false)
2026/09/09 17:48:55 Forwarding clip with motion=1% (detections: false)
2026/09/09 17:49:05 Forwarding clip with motion=1% (detections: false)
2026/09/09 17:50:04 Shutting down...
2026/09/09 17:50:04 Exiting
2026/09/09 17:50:32 Loaded 2 classifier(s)
2026/09/09 17:50:32 Listening on :8081
2026/09/09 17:50:32 decode error from 127.0.0.1:43110: auth handshake read: EOF
2026/09/09 17:51:35 Forwarding clip with motion=1% (detections: false)
2026/09/09 17:53:15 Forwarding clip with motion=5% (detections: false)
2026/09/09 17:53:27 Forwarding clip with motion=56% (detections: true)
2026/09/09 17:53:38 Forwarding clip with motion=35% (detections: true)
2026/09/09 17:53:48 Forwarding clip with motion=4% (detections: true)
2026/09/09 17:53:58 Forwarding clip with motion=14% (detections: true)
2026/09/09 17:54:07 Forwarding clip with motion=7% (detections: true)
2026/09/09 17:54:17 Forwarding clip with motion=38% (detections: true)
2026/09/09 17:54:27 Forwarding clip with motion=15% (detections: true)
2026/09/09 17:54:37 Forwarding clip with motion=18% (detections: true)
2026/09/09 17:54:47 Forwarding clip with motion=12% (detections: true)
2026/09/09 17:54:57 Forwarding clip with motion=32% (detections: true)
2026/09/09 17:55:07 Forwarding clip with motion=12% (detections: true)
2026/09/09 17:55:17 Forwarding clip with motion=2% (detections: true)
2026/09/09 17:55:27 Forwarding clip with motion=47% (detections: true)
2026/09/09 17:55:35 Forwarding clip with motion=27% (detections: false)
2026/09/09 18:13:46 Forwarding clip with motion=46% (detections: true)
2026/09/09 18:13:57 Forwarding clip with motion=40% (detections: true)
2026/09/09 18:14:06 Forwarding clip with motion=100% (detections: true)
2026/09/09 18:14:15 Forwarding clip with motion=98% (detections: true)
2026/09/09 18:14:27 Forwarding clip with motion=79% (detections: true)
2026/09/09 18:18:25 Shutting down...
2026/09/09 18:18:25 Exiting
2026/09/09 18:19:05 Loaded 2 classifier(s)
2026/09/09 18:19:05 Listening on :8081
2026/09/09 18:19:05 decode error from 127.0.0.1:49826: auth handshake read: EOF
2026/09/09 18:19:19 Shutting down...
2026/09/09 18:19:19 Exiting
2026/09/09 18:21:35 Loaded 2 classifier(s)
2026/09/09 18:21:35 Listening on :8081
2026/09/09 18:21:35 decode error from 127.0.0.1:52964: auth handshake read: EOF
2026/09/09 18:24:37 Forwarding clip with motion=1% (detections: false)
2026/09/09 18:25:05 Shutting down...
2026/09/09 18:25:05 Exiting
2026/09/09 18:26:07 Loaded 2 classifier(s)
2026/09/09 18:26:07 Listening on :8081
2026/09/09 18:26:07 decode error from 127.0.0.1:58618: auth handshake read: EOF
2026/09/09 18:32:14 Shutting down...
2026/09/09 18:32:14 Exiting
2026/09/09 18:32:17 Loaded 2 classifier(s)
2026/09/09 18:32:17 Listening on :8081
2026/09/09 18:32:17 decode error from 127.0.0.1:34942: auth handshake read: EOF
2026/09/09 18:32:32 Forwarding clip with motion=100% (detections: false)
2026/09/09 18:58:10 Forwarding clip with motion=2% (detections: false)
2026/09/09 18:59:00 Forwarding clip with motion=2% (detections: false)
2026/09/09 19:00:30 Forwarding clip with motion=1% (detections: false)
2026/09/09 19:01:10 Forwarding clip with motion=1% (detections: false)
2026/09/09 19:01:19 Forwarding clip with motion=2% (detections: false)
2026/09/09 19:01:40 Forwarding clip with motion=2% (detections: false)
2026/09/09 19:08:20 Forwarding clip with motion=2% (detections: false)
2026/09/09 19:09:50 Forwarding clip with motion=30% (detections: false)
2026/09/09 19:10:19 Forwarding clip with motion=47% (detections: false)
2026/09/09 19:10:29 Forwarding clip with motion=73% (detections: false)
2026/09/09 19:43:00 Forwarding clip with motion=100% (detections: false)
2026/09/09 19:43:40 Forwarding clip with motion=100% (detections: false)
2026/09/09 19:43:49 Forwarding clip with motion=100% (detections: false)
2026/09/09 19:46:35 Forwarding clip with motion=100% (detections: false)
2026/09/09 20:04:49 Forwarding clip with motion=41% (detections: false)
2026/09/09 20:09:49 Forwarding clip with motion=81% (detections: true)
2026/09/09 20:09:59 Forwarding clip with motion=60% (detections: true)
2026/09/09 20:10:09 Forwarding clip with motion=33% (detections: false)
2026/09/09 20:10:19 Forwarding clip with motion=11% (detections: false)
2026/09/09 20:10:29 Forwarding clip with motion=12% (detections: false)
2026/09/09 20:10:39 Forwarding clip with motion=15% (detections: false)
2026/09/09 20:10:49 Forwarding clip with motion=3% (detections: false)
2026/09/09 20:10:59 Forwarding clip with motion=6% (detections: false)
2026/09/09 20:11:09 Forwarding clip with motion=1% (detections: false)
2026/09/09 20:11:29 Forwarding clip with motion=3% (detections: false)
2026/09/09 20:11:39 Forwarding clip with motion=6% (detections: false)
2026/09/09 20:11:49 Forwarding clip with motion=26% (detections: false)
2026/09/09 20:11:59 Forwarding clip with motion=28% (detections: false)
2026/09/09 20:12:29 Forwarding clip with motion=2% (detections: false)
2026/09/09 20:12:39 Forwarding clip with motion=4% (detections: false)
2026/09/09 20:12:49 Forwarding clip with motion=20% (detections: true)
2026/09/09 20:13:00 Forwarding clip with motion=15% (detections: true)
2026/09/09 20:13:09 Forwarding clip with motion=18% (detections: false)
2026/09/09 20:13:19 Forwarding clip with motion=21% (detections: false)
2026/09/09 20:13:29 Forwarding clip with motion=16% (detections: false)
2026/09/09 20:13:39 Forwarding clip with motion=1% (detections: false)
2026/09/09 20:13:59 Forwarding clip with motion=2% (detections: false)
2026/09/09 20:14:09 Forwarding clip with motion=2% (detections: false)
2026/09/09 20:14:29 Forwarding clip with motion=7% (detections: false)
2026/09/09 20:14:39 Forwarding clip with motion=3% (detections: false)
2026/09/09 20:14:49 Forwarding clip with motion=22% (detections: false)
2026/09/09 20:14:59 Forwarding clip with motion=15% (detections: false)
2026/09/09 20:15:09 Forwarding clip with motion=4% (detections: false)
2026/09/09 20:15:19 Forwarding clip with motion=10% (detections: false)
2026/09/09 20:15:29 Forwarding clip with motion=6% (detections: false)
2026/09/09 20:15:39 Forwarding clip with motion=26% (detections: false)
2026/09/09 20:15:49 Forwarding clip with motion=24% (detections: false)
2026/09/09 20:15:59 Forwarding clip with motion=5% (detections: false)
2026/09/09 20:16:09 Forwarding clip with motion=7% (detections: false)
2026/09/09 20:16:19 Forwarding clip with motion=8% (detections: false)
2026/09/09 20:16:49 Forwarding clip with motion=3% (detections: false)
2026/09/09 20:16:59 Forwarding clip with motion=5% (detections: false)
2026/09/09 20:17:09 Forwarding clip with motion=3% (detections: true)
2026/09/09 20:17:19 Forwarding clip with motion=26% (detections: true)
2026/09/09 20:17:29 Forwarding clip with motion=29% (detections: true)
2026/09/09 20:17:39 Forwarding clip with motion=29% (detections: true)
2026/09/09 20:17:49 Forwarding clip with motion=8% (detections: true)
2026/09/09 20:17:59 Forwarding clip with motion=7% (detections: false)
2026/09/09 20:18:09 Forwarding clip with motion=2% (detections: false)
2026/09/09 20:18:19 Forwarding clip with motion=7% (detections: true)
2026/09/09 20:18:29 Forwarding clip with motion=29% (detections: true)
2026/09/09 20:18:39 Forwarding clip with motion=15% (detections: true)
2026/09/09 20:18:49 Forwarding clip with motion=23% (detections: false)
2026/09/09 20:18:59 Forwarding clip with motion=25% (detections: false)
2026/09/09 20:19:09 Forwarding clip with motion=2% (detections: false)
2026/09/09 20:19:29 Forwarding clip with motion=4% (detections: false)
2026/09/09 20:19:39 Forwarding clip with motion=7% (detections: false)
2026/09/09 20:19:49 Forwarding clip with motion=22% (detections: true)
2026/09/09 20:19:59 Forwarding clip with motion=26% (detections: true)
2026/09/09 20:20:09 Forwarding clip with motion=29% (detections: true)
2026/09/09 20:20:39 Forwarding clip with motion=3% (detections: true)
2026/09/09 20:20:49 Forwarding clip with motion=5% (detections: false)
2026/09/09 20:23:39 Forwarding clip with motion=31% (detections: true)
2026/09/09 20:23:49 Forwarding clip with motion=12% (detections: false)
2026/09/09 20:23:59 Forwarding clip with motion=4% (detections: false)
2026/09/09 20:24:09 Forwarding clip with motion=2% (detections: false)
2026/09/09 20:24:19 Forwarding clip with motion=7% (detections: false)
2026/09/09 20:24:29 Forwarding clip with motion=3% (detections: false)
2026/09/09 20:24:39 Forwarding clip with motion=1% (detections: false)
2026/09/09 20:24:49 Forwarding clip with motion=6% (detections: true)
2026/09/09 20:24:59 Forwarding clip with motion=1% (detections: true)
2026/09/09 20:25:09 Forwarding clip with motion=1% (detections: false)
2026/09/09 20:25:19 Forwarding clip with motion=1% (detections: true)
2026/09/09 20:25:29 Forwarding clip with motion=1% (detections: true)
2026/09/09 20:25:39 Forwarding clip with motion=2% (detections: true)
2026/09/09 20:25:49 Forwarding clip with motion=12% (detections: false)
2026/09/09 20:25:59 Forwarding clip with motion=1% (detections: false)
2026/09/09 20:26:09 Forwarding clip with motion=1% (detections: false)
2026/09/09 20:26:19 Forwarding clip with motion=1% (detections: false)
2026/09/09 20:26:39 Forwarding clip with motion=2% (detections: false)
2026/09/09 20:26:59 Forwarding clip with motion=2% (detections: false)
2026/09/09 20:27:09 Forwarding clip with motion=28% (detections: false)
2026/09/09 20:27:19 Forwarding clip with motion=20% (detections: false)
2026/09/09 20:33:29 Forwarding clip with motion=56% (detections: true)
2026/09/09 20:33:40 Forwarding clip with motion=59% (detections: true)
2026/09/09 20:33:49 Forwarding clip with motion=25% (detections: true)
2026/09/09 20:33:59 Forwarding clip with motion=15% (detections: true)
2026/09/09 20:34:09 Forwarding clip with motion=21% (detections: true)
2026/09/09 20:34:39 Forwarding clip with motion=27% (detections: true)
2026/09/09 20:34:49 Forwarding clip with motion=68% (detections: true)
2026/09/09 20:34:59 Forwarding clip with motion=8% (detections: true)
2026/09/09 20:35:09 Forwarding clip with motion=42% (detections: true)
2026/09/09 20:35:19 Forwarding clip with motion=44% (detections: true)
2026/09/09 20:35:29 Forwarding clip with motion=11% (detections: false)
2026/09/09 20:35:40 Forwarding clip with motion=53% (detections: true)
2026/09/09 20:35:50 Forwarding clip with motion=36% (detections: true)
2026/09/09 20:35:59 Forwarding clip with motion=26% (detections: true)
2026/09/09 20:36:10 Forwarding clip with motion=37% (detections: true)
2026/09/09 20:36:20 Forwarding clip with motion=33% (detections: true)
2026/09/09 20:36:30 Forwarding clip with motion=25% (detections: true)
2026/09/09 20:36:40 Forwarding clip with motion=15% (detections: true)
2026/09/09 20:36:50 Forwarding clip with motion=17% (detections: false)
2026/09/09 20:37:00 Forwarding clip with motion=20% (detections: true)
2026/09/09 20:37:10 Forwarding clip with motion=2% (detections: false)
2026/09/09 20:37:20 Forwarding clip with motion=11% (detections: true)
2026/09/09 20:37:30 Forwarding clip with motion=21% (detections: true)
2026/09/09 20:37:39 Forwarding clip with motion=30% (detections: true)
2026/09/09 20:37:49 Forwarding clip with motion=3% (detections: false)
2026/09/09 20:37:59 Forwarding clip with motion=2% (detections: true)
2026/09/09 20:38:09 Forwarding clip with motion=4% (detections: false)
2026/09/09 20:38:19 Forwarding clip with motion=4% (detections: false)
2026/09/09 20:38:29 Forwarding clip with motion=3% (detections: false)
2026/09/09 20:38:39 Forwarding clip with motion=2% (detections: true)
2026/09/09 20:38:59 Forwarding clip with motion=5% (detections: false)
2026/09/09 20:39:09 Forwarding clip with motion=7% (detections: false)
2026/09/09 20:39:29 Forwarding clip with motion=1% (detections: false)
2026/09/09 20:39:39 Forwarding clip with motion=7% (detections: false)
2026/09/09 20:39:49 Forwarding clip with motion=1% (detections: false)
2026/09/09 20:40:29 Forwarding clip with motion=2% (detections: true)
2026/09/09 20:40:39 Forwarding clip with motion=6% (detections: false)
2026/09/09 20:40:49 Forwarding clip with motion=47% (detections: true)
2026/09/09 20:41:09 Forwarding clip with motion=82% (detections: true)
2026/09/09 20:50:09 Forwarding clip with motion=1% (detections: false)
2026/09/09 20:56:12 Shutting down...
2026/09/09 20:56:12 Exiting
2026/09/09 20:56:33 Loaded 2 classifier(s)
2026/09/09 20:56:33 Listening on :8081
2026/09/09 20:56:33 decode error from 127.0.0.1:37776: auth handshake read: EOF
2026/09/09 20:58:45 Forwarding clip with motion=100% (detections: false)
2026/09/09 20:58:45 Forwarding clip with motion=100% (detections: false)
2026/09/09 21:29:56 Forwarding clip with motion=85% (detections: false)
+5
View File
@@ -0,0 +1,5 @@
457603 supervise
457619 coordinator
457642 terp
457658 mofin
457674 runCam
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
module runCam
go 1.26.3
require frame v0.0.0
require (
gocv.io/x/gocv v0.43.0
golang.org/x/crypto v0.52.0 // indirect
)
replace frame => ../frame
+4
View File
@@ -0,0 +1,4 @@
gocv.io/x/gocv v0.43.0 h1:PFNpRUcV8fgBRDbVHHN+4BDZjjPnVveo5N/+e15BTuA=
gocv.io/x/gocv v0.43.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
+380
View File
@@ -0,0 +1,380 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
"frame"
)
// camHandle captures the lifecycle state of one running camera so the
// rebalance loop and the camera's own goroutine can safely agree on removal
// (comparing handles by identity avoids deleting a replacement camera).
type camHandle struct {
ctx context.Context
cancel context.CancelFunc
}
func main() {
var (
mofinAddr string
camMin int
camMax int
clipDur time.Duration
rescanInterval time.Duration
username string
cameraID string
passphrase string
authToken string
videoFPS int
videoWidth int
videoHeight int
pbkdf2Iter int
)
flag.StringVar(&mofinAddr, "mofin", "localhost:8083", "mofin motion detection address")
flag.IntVar(&camMin, "min-cam", 0, "minimum camera index to scan")
flag.IntVar(&camMax, "max-cam", 10, "maximum camera index to scan")
flag.DurationVar(&clipDur, "clip-duration", 10*time.Second, "duration of each clip")
flag.DurationVar(&rescanInterval, "rescan-interval", 10*time.Second, "interval between hotplug rescans")
flag.StringVar(&username, "user", "oko", "username for frame source data")
flag.StringVar(&cameraID, "camera-id", "", "camera identifier prefix (required)")
flag.StringVar(&passphrase, "passphrase", "", "encryption passphrase (empty = no encryption)")
flag.StringVar(&authToken, "auth-token", "", "shared pipeline auth token (or OKO_AUTH_TOKEN)")
flag.IntVar(&videoFPS, "video-fps", 5, "max frames per second read from the camera and stored in clips (0 = device native rate)")
flag.IntVar(&videoWidth, "video-width", 0, "capture width in pixels (0 = device default)")
flag.IntVar(&videoHeight, "video-height", 0, "capture height in pixels (0 = device default)")
flag.IntVar(&pbkdf2Iter, "pbkdf2-iters", frame.DefaultPBKDF2Iter, "PBKDF2 iterations for clip encryption (1..1000000)")
flag.Parse()
if cameraID == "" {
fmt.Fprintln(os.Stderr, "-camera-id is required")
flag.Usage()
os.Exit(1)
}
if pbkdf2Iter < 1 || pbkdf2Iter > frame.MaxPBKDF2Iter {
log.Fatalf("pbkdf2-iters must be in [1, %d], got %d", frame.MaxPBKDF2Iter, pbkdf2Iter)
}
if videoFPS < 0 || videoFPS > 60 {
log.Fatalf("video-fps must be in [0, 60], got %d", videoFPS)
}
if videoWidth < 0 || videoHeight < 0 {
log.Fatalf("video dimensions must be >= 0, got %dx%d", videoWidth, videoHeight)
}
frame.SetDefaultPBKDF2Iter(pbkdf2Iter)
tok := authToken
if tok == "" {
tok = os.Getenv("OKO_AUTH_TOKEN")
}
if tok == "" {
log.Fatal("authentication required: set -auth-token or OKO_AUTH_TOKEN")
}
frame.SetAuthToken(tok)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
var (
cameras = make(map[int]*camHandle)
camerasMu sync.Mutex
wg sync.WaitGroup
)
// Hotplug loop: scan + rebalance continuously. It lives behind its own
// WaitGroup so shutdown can wait for it to stop scheduling new cameras
// BEFORE wg.Wait() (rebalance is the only caller of wg.Add; waiting on it
// first removes the Add/Wait misuse race).
var rebalanceWg sync.WaitGroup
rebalanceWg.Add(1)
go func() {
defer rebalanceWg.Done()
ticker := time.NewTicker(rescanInterval)
defer ticker.Stop()
for {
rebalance(ctx, &wg, &camerasMu, cameras, camMin, camMax, mofinAddr, clipDur, username, cameraID, passphrase, videoFPS, videoWidth, videoHeight)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
<-ctx.Done()
log.Println("Shutting down...")
camerasMu.Lock()
for _, h := range cameras {
h.cancel()
}
camerasMu.Unlock()
rebalanceWg.Wait()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
log.Println("All cameras stopped")
case <-time.After(5 * time.Second): // TODO: Make configurable
log.Println("Forced shutdown after timeout")
}
}
func rebalance(ctx context.Context, wg *sync.WaitGroup, mu *sync.Mutex, cameras map[int]*camHandle, camMin, camMax int, mofinAddr string, clipDur time.Duration, username, cameraID, passphrase string, videoFPS, videoWidth, videoHeight int) {
if ctx.Err() != nil {
return
}
mu.Lock()
inUse := make(map[int]struct{}, len(cameras))
for idx := range cameras {
inUse[idx] = struct{}{}
}
mu.Unlock()
// Probe only devices not already streaming: opening a busy capture handle
// can transiently report a zero size and flap between teardown and
// restart. A camera that dies is detected by its own goroutine (read
// failure) and removed from the map, after which it is probed again.
indices, err := frame.ScanSkipExcluding(camMin, camMax, 3, inUse)
if err != nil {
return
}
mu.Lock()
defer mu.Unlock()
// Stop tracked cameras that have disappeared. Cameras currently streaming
// are excluded from the scan, so treat them as still present; vanished
// ones leave the map themselves via the camera goroutine.
for idx, h := range cameras {
if _, busy := inUse[idx]; busy {
continue
}
if !contains(indices, idx) {
log.Printf("Camera %d: disconnected", idx)
h.cancel()
delete(cameras, idx)
}
}
// Start cameras that have appeared
for _, idx := range indices {
if _, exists := cameras[idx]; !exists {
camCtx, c := context.WithCancel(ctx)
h := &camHandle{ctx: camCtx, cancel: c}
cameras[idx] = h
wg.Add(1)
go func(i int, handle *camHandle) {
defer wg.Done()
log.Printf("Camera %d: connected", i)
runCamera(handle.ctx, i, mofinAddr, clipDur, username, cameraID, passphrase, videoFPS, videoWidth, videoHeight)
log.Printf("Camera %d: stopped", i)
mu.Lock()
if cameras[i] == handle {
delete(cameras, i)
}
mu.Unlock()
}(idx, h)
}
}
}
func contains(slice []int, val int) bool {
for _, v := range slice {
if v == val {
return true
}
}
return false
}
// ThrottleFrames forwards the latest frame received from src to dst at a
// maximum rate of fps frames per second, so a slow reader only ever sees the
// newest frame. If the source closes, the function returns.
func ThrottleFrames(ctx context.Context, src <-chan frame.Frame, dst chan<- frame.Frame, fps int) {
interval := time.Second / time.Duration(fps)
if fps <= 0 {
interval = time.Second / 5
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
var (
lastFrame frame.Frame
haveFrame bool
frameMu sync.Mutex
)
go func() {
for {
select {
case <-ctx.Done():
return
case f, ok := <-src:
if !ok {
return
}
frameMu.Lock()
lastFrame = f
haveFrame = true
frameMu.Unlock()
}
}
}()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
frameMu.Lock()
if !haveFrame {
frameMu.Unlock()
continue
}
f := lastFrame
frameMu.Unlock()
select {
case dst <- f:
default:
}
}
}
}
func runCamera(ctx context.Context, camIndex int, mofinAddr string, clipDur time.Duration, username, cameraID, passphrase string, videoFPS, videoWidth, videoHeight int) {
frames := make(chan frame.Frame, 60)
if err := frame.Capture(ctx, camIndex, videoFPS, videoWidth, videoHeight, frames); err != nil {
log.Printf("Camera %d: %v", camIndex, err)
return
}
log.Printf("Camera %d: started", camIndex)
sourceData := fmt.Sprintf("%s--%s/%d", username, cameraID, camIndex)
// Throttle to the configured FPS, then build clips from the sampled frames.
// Clips are handed to a bounded outbound queue drained by a sender
// goroutine, so a slow or downed mofin never stalls the capture path —
// frames keep being sampled and the newest frames are preserved.
clipFrames := make(chan frame.Frame, 60)
var throttleWg sync.WaitGroup
throttleWg.Add(1)
go func() {
defer throttleWg.Done()
ThrottleFrames(ctx, frames, clipFrames, videoFPS)
}()
outQueue := make(chan frame.Clip, 4)
var senderWg sync.WaitGroup
senderWg.Add(1)
go func() {
defer senderWg.Done()
for clp := range outQueue {
if err := clp.SendClipRetry(mofinAddr, 6); err != nil {
log.Printf("Camera %d: send to mofin failed, clip dropped: %v", camIndex, err)
} else {
log.Printf("Camera %d: sent clip with %d frames to %s", camIndex, len(clp.PixelMats), mofinAddr)
}
}
}()
clipWg := sync.WaitGroup{}
clipWg.Add(1)
go func() {
defer clipWg.Done()
runClipBuilder(ctx, camIndex, clipDur, sourceData, passphrase, clipFrames, outQueue)
}()
clipWg.Wait()
close(outQueue)
senderWg.Wait()
throttleWg.Wait()
}
func runClipBuilder(ctx context.Context, camIndex int, clipDur time.Duration, sourceData, passphrase string, frames <-chan frame.Frame, outQueue chan<- frame.Clip) {
ticker := time.NewTicker(clipDur)
defer ticker.Stop()
var buf []frame.Frame
var pixelBuf [][]byte
var guidBuf [][]byte
var tsBuf []uint64
flush := func() {
if len(buf) == 0 {
return
}
clip := frame.Clip{
PixelMats: pixelBuf,
Guids: guidBuf,
Timestamps: tsBuf,
Width: buf[0].Width,
Height: buf[0].Height,
Types: buf[0].GocvImageType,
Channels: buf[0].Channels,
SourceData: sourceData,
}
if passphrase != "" {
if err := clip.Encrypt(passphrase); err != nil {
log.Printf("Camera %d: encrypt error: %v", camIndex, err)
}
}
buf = buf[:0]
pixelBuf = pixelBuf[:0]
guidBuf = guidBuf[:0]
tsBuf = tsBuf[:0]
if len(clip.PixelMats) == 0 {
return
}
// Non-blocking enqueue: the sender goroutine owns retries and network
// blocking. If the queue is full (mofin down for a while), drop this
// clip rather than stall capture and lose newest frames.
select {
case outQueue <- clip:
default:
log.Printf("Camera %d: outbound queue full, dropping clip (%d frames)", camIndex, len(clip.PixelMats))
// Flush in reverse on drop would hold newer frames back; the
// queue drains continuously and the next clip is a full window.
}
}
defer flush()
for {
select {
case <-ctx.Done():
flush()
return
case f := <-frames:
buf = append(buf, f)
pixelBuf = append(pixelBuf, f.PixelBytes)
guidBuf = append(guidBuf, f.Guid)
tsBuf = append(tsBuf, f.Timestamp)
case <-ticker.C:
flush()
}
}
}
Executable
BIN
View File
Binary file not shown.
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"testing"
)
func TestContains_Found(t *testing.T) {
slice := []int{1, 2, 3, 4, 5}
if !contains(slice, 3) {
t.Error("contains({1,2,3,4,5}, 3) = false, want true")
}
}
func TestContains_NotFound(t *testing.T) {
slice := []int{1, 2, 3, 4, 5}
if contains(slice, 6) {
t.Error("contains({1,2,3,4,5}, 6) = true, want false")
}
}
func TestContains_Empty(t *testing.T) {
if contains(nil, 1) {
t.Error("contains(nil, 1) = true, want false")
}
}
func TestContains_First(t *testing.T) {
if !contains([]int{42}, 42) {
t.Error("contains({42}, 42) = false, want true")
}
}
Executable
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env bash
# supervise.sh — one-shot test supervisor for the oko pipeline.
#
# Re-executes itself inside the oko-dev distrobox (binaries link container
# OpenCV libs), builds all runtime services, generates/validates configs,
# starts coordinator → terp → mofin → runCam in order,
# and tears everything down on Ctrl-C.
#
# Usage:
# ./supervise.sh [options] # run the full stack in foreground
# ./supervise.sh stop # kill a stack left running elsewhere
#
# Options:
# --no-camera skip runCam (pipeline-only test, no hardware needed)
# --no-build reuse binaries from ./run/bin without rebuilding
# --passphrase STRING enable end-to-end clip encryption (default: plaintext)
# --auth-token STRING shared pipeline auth token (default: random per run)
# --user NAME camera owner username (default: oko)
# --camera-id ID camera identifier (default: front)
# --threshold N mofin pixel-value sensitivity (default: 30)
#
# Matrix settings for coordinator come from OKO_MATRIX_HS, OKO_MATRIX_USER,
# OKO_MATRIX_TOKEN_FILE (or OKO_MATRIX_TOKEN), OKO_CLIPS_ROOM,
# OKO_DETECTIONS_ROOM, and OKO_VIEW_ROOM.
set -Eeuo pipefail
# ---------- re-enter inside the build container ----------
if [[ ! -f /run/.containerenv && ! -f /.dockerenv && -z "${container:-}" ]]; then
DISTROBOX_BIN="$(command -v distrobox || echo /usr/bin/distrobox)"
exec "$DISTROBOX_BIN" enter oko-dev -- "$(readlink -f "$0")" "$@"
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
[[ "${SUPERVISE_DEBUG:-0}" == 1 ]] && set -x
NO_CAMERA=0
NO_BUILD=0
PASSPHRASE=""
AUTH_TOKEN=""
USER_NAME="oko"
CAMERA_ID="front"
MOFIN_THRESHOLD=30
PBKDF2_ITERS=600000
VIDEO_WIDTH=0
VIDEO_HEIGHT=0
while [[ $# -gt 0 ]]; do
case "$1" in
--no-camera) NO_CAMERA=1 ;;
--no-build) NO_BUILD=1 ;;
--passphrase) PASSPHRASE="$2"; shift ;;
--auth-token) AUTH_TOKEN="$2"; shift ;;
--user) USER_NAME="$2"; shift ;;
--camera-id) CAMERA_ID="$2"; shift ;;
--threshold) MOFIN_THRESHOLD="$2"; shift ;;
--pbkdf2-iters) PBKDF2_ITERS="$2"; shift ;;
--video-width) VIDEO_WIDTH="$2"; shift ;;
--video-height) VIDEO_HEIGHT="$2"; shift ;;
stop) STOP=1 ;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
shift
done
: "${AUTH_TOKEN:=$(openssl rand -hex 16)}"
: "${OKO_MATRIX_HS:=https://ayrc.online}"
: "${OKO_MATRIX_USER:=@okobot:ayrc.online}"
: "${OKO_MATRIX_TOKEN_FILE:=$SCRIPT_DIR/matrix.token}"
: "${OKO_CLIPS_ROOM:=!0hCGPJph-odzFEOScsnAOBlPHSolX8rwbh3mxQNNUrg}"
: "${OKO_DETECTIONS_ROOM:=!XqatfFvJJT6xq8B0p7b5NL2RYRCBS2Fa3ItFu8xV2gQ}"
: "${OKO_VIEW_ROOM:=!wyLfOhunJTOzeXHFfAc6CRCCII7KlA1Pm2MkkFf40tE}"
RUN_DIR="$SCRIPT_DIR/run"
LOG_DIR="$RUN_DIR/logs"
BIN_DIR="$RUN_DIR/bin"
PID_FILE="$RUN_DIR/pids"
if [[ "${STOP:-0}" == 1 ]]; then
if [[ -f "$PID_FILE" ]]; then
while read -r pid name; do
[[ -z "$pid" ]] && continue
if [[ "$name" == "supervise" ]]; then
# killing the supervisor triggers its EXIT trap, which cascade-
# kills every service group it manages.
kill "$pid" 2>/dev/null && echo "stopped supervisor ($pid)"
else
kill -- -"$pid" 2>/dev/null && echo "stopped $name ($pid)" || echo "already dead: $name ($pid)"
fi
done < "$PID_FILE"
sleep 1
while read -r pid name; do
[[ -z "$pid" || "$name" == "supervise" ]] && continue
if kill -0 "$pid" 2>/dev/null; then
kill -9 -- -"$pid" 2>/dev/null
echo "forced: $name ($pid)"
fi
done < "$PID_FILE"
rm -f "$PID_FILE"
else
echo "no pidfile at $PID_FILE" >&2; exit 1
fi
exit 0
fi
# ---------- helpers ----------
log() { printf '\033[1;34m[supervise]\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[supervise] ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
port_open() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null || return 1; exec 3>&- 3<&-; return 0; }
wait_port() {
local port="$1" name="$2" i
for i in $(seq 1 50); do
port_open "$port" && { log "$name is listening on :$port"; return 0; }
sleep 0.2
done
die "$name did not open port $port — check $LOG_DIR/$name.log"
}
declare -a CHILD_PIDS=()
cleanup() {
log "shutting down..."
local pid
for pid in "${CHILD_PIDS[@]:-}"; do
[[ -n "$pid" ]] && kill -- -"$pid" 2>/dev/null
done
wait 2>/dev/null
rm -f "$PID_FILE"
log "all services stopped."
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
start_service() {
local name="$1" port="$2"; shift 2
log "starting $name ..."
# setsid puts the service (and any bash -c wrapper) in its own process
# group so stop/cleanup can kill the whole tree atomically with
# `kill -- -PID`; a bare SIGTERM to a wrapper subshell would orphan it.
setsid "$@" >>"$LOG_DIR/$name.log" 2>&1 &
local pid=$!
CHILD_PIDS+=("$pid")
echo "$pid $name" >>"$PID_FILE"
sleep 0.5
if ! kill -0 "$pid" 2>/dev/null; then
tail -n 20 "$LOG_DIR/$name.log" >&2
die "$name exited immediately — see $LOG_DIR/$name.log"
fi
if [[ -n "$port" ]]; then
wait_port "$port" "$name"
fi
}
# ---------- preflight ----------
command -v go >/dev/null || die "required tool 'go' not found in container"
mkdir -p "$BIN_DIR" "$LOG_DIR"
if [[ -f "$PID_FILE" ]]; then
while read -r pid _; do
kill -0 "$pid" 2>/dev/null && die "stack already running (pid $pid). Use './supervise.sh stop' first."
done < "$PID_FILE"
rm -f "$PID_FILE"
fi
: > "$PID_FILE"
for p in 8081 8082 8083; do
if port_open "$p"; then
die "port $p already in use by something outside this supervisor."
fi
done
SERVICES=(coordinator terp mofin runCam)
# ---------- build ----------
if [[ "$NO_BUILD" == 0 ]]; then
log "building ${SERVICES[*]} ..."
for svc in "${SERVICES[@]}"; do
( cd "$svc" && go build -o "$BIN_DIR/$svc" . ) || die "build failed: $svc"
done
log "build complete."
else
for svc in "${SERVICES[@]}"; do
[[ -x "$BIN_DIR/$svc" ]] || die "--no-build set but $BIN_DIR/$svc missing; run a build first."
done
fi
# ---------- ensure configurations ----------
log "ensuring configurations ..."
TERP_DIR="$RUN_DIR/terp"
mkdir -p "$TERP_DIR/classifiers"
for cascade_src in /usr/share/opencv4/haarcascades /usr/share/OpenCV/haarcascades; do
if [[ -d "$cascade_src" ]]; then
cp -u "$cascade_src"/haarcascade_fullbody.xml "$TERP_DIR/classifiers/" 2>/dev/null
cp -u "$cascade_src"/haarcascade_frontalface_default.xml "$TERP_DIR/classifiers/" 2>/dev/null
break
fi
done
ls "$TERP_DIR/classifiers/"*.xml >/dev/null 2>&1 || die "no Haar cascades found for terp (looked in /usr/share/opencv4/haarcascades)"
SOURCE_DATA="${USER_NAME}--${CAMERA_ID}"
# ---------- launch (dependency order) ----------
echo "$$ supervise" >>"$PID_FILE"
start_service coordinator 8082 "$BIN_DIR/coordinator" \
-listen ":8082" \
-auth-token "$AUTH_TOKEN" \
-passphrase "$PASSPHRASE" \
-pbkdf2-iters "$PBKDF2_ITERS" \
-matrix-homeserver "${OKO_MATRIX_HS:-}" \
-matrix-user "${OKO_MATRIX_USER:-}" \
-matrix-token-file "${OKO_MATRIX_TOKEN_FILE:-}" \
-clips-room "${OKO_CLIPS_ROOM:-}" \
-detections-room "${OKO_DETECTIONS_ROOM:-}" \
-view-room "${OKO_VIEW_ROOM:-}"
start_service terp 8081 bash -c "cd '$TERP_DIR' && exec '$BIN_DIR/terp' \
-listen ':8081' \
-sendoff 'localhost:8082' \
-auth-token '$AUTH_TOKEN' \
-classifiers 'classifiers/*.xml' \
-motion-percent 0 \
-passphrase '$PASSPHRASE' \
-pbkdf2-iters '$PBKDF2_ITERS'"
start_service mofin 8083 "$BIN_DIR/mofin" \
-listen ":8083" \
-terp "localhost:8081" \
-auth-token "$AUTH_TOKEN" \
-threshold "$MOFIN_THRESHOLD" \
-passphrase "$PASSPHRASE" \
-pbkdf2-iters "$PBKDF2_ITERS"
RUN_CAM=1
if [[ "$NO_CAMERA" == 0 ]]; then
ls /dev/video* >/dev/null 2>&1 || { log "WARNING: no /dev/video* devices visible; skipping runCam (pipeline-only mode)"; RUN_CAM=0; }
else
RUN_CAM=0
fi
if [[ "$RUN_CAM" == 1 ]]; then
start_service runCam "" "$BIN_DIR/runCam" \
-mofin "localhost:8083" \
-auth-token "$AUTH_TOKEN" \
-min-cam 0 -max-cam 10 \
-video-width "$VIDEO_WIDTH" -video-height "$VIDEO_HEIGHT" \
-user "$USER_NAME" \
-camera-id "$CAMERA_ID" \
-passphrase "$PASSPHRASE" \
-pbkdf2-iters "$PBKDF2_ITERS"
fi
# ---------- status ----------
echo
log "=== oko test stack is up ==="
printf ' %-18s %-12s %s\n' SERVICE PORT LOG
printf ' %-18s %-12s %s\n' coordinator 8082 "$LOG_DIR/coordinator.log"
printf ' %-18s %-12s %s\n' terp 8081 "$LOG_DIR/terp.log"
printf ' %-18s %-12s %s\n' mofin 8083 "$LOG_DIR/mofin.log"
if [[ "$RUN_CAM" == 1 ]]; then
printf ' %-18s %-12s %s\n' runCam — "$LOG_DIR/runCam.log"
fi
echo
log "motion clips land in the configured Matrix rooms (OKO_CLIPS_ROOM / OKO_DETECTIONS_ROOM)"
log "Ctrl-C to stop everything."
wait
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
module terp
go 1.26.3
require frame v0.0.0
require (
gocv.io/x/gocv v0.43.0
golang.org/x/crypto v0.52.0 // indirect
)
replace frame => ../frame
+4
View File
@@ -0,0 +1,4 @@
gocv.io/x/gocv v0.43.0 h1:PFNpRUcV8fgBRDbVHHN+4BDZjjPnVveo5N/+e15BTuA=
gocv.io/x/gocv v0.43.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
+219
View File
@@ -0,0 +1,219 @@
package main
import (
"context"
"flag"
"log"
"net"
"os"
"os/signal"
"sync"
"syscall"
"time"
"frame"
)
const (
workerCount = 4
// connLimit bounds concurrent inbound connections so a connection flood
// cannot exhaust goroutines or file descriptors.
connLimit = 16
// clipChanCap gives in-flight ingest handlers somewhere to park a decoded
// clip without holding the connection's socket open for the workers.
clipChanCap = 32
shutdownGrace = 5 * time.Second
)
func main() {
var (
listenAddr string
sendoffAddr string
classifierGlob string
motionPercent int
passphrase string
authToken string
pbkdf2Iter int
)
flag.StringVar(&listenAddr, "listen", ":8081", "address to listen for incoming clips")
flag.StringVar(&sendoffAddr, "sendoff", "localhost:8082", "address to forward classified clips")
flag.StringVar(&classifierGlob, "classifiers", "classifiers/*.xml", "glob pattern for Haar cascade XMLs")
flag.IntVar(&motionPercent, "motion-percent", 0, "minimum highest motion percent [0-100] required to classify and forward (0 = always classify)")
flag.StringVar(&passphrase, "passphrase", "", "decryption passphrase (empty = no encryption)")
flag.StringVar(&authToken, "auth-token", "", "shared pipeline auth token (or OKO_AUTH_TOKEN)")
flag.IntVar(&pbkdf2Iter, "pbkdf2-iters", frame.DefaultPBKDF2Iter, "PBKDF2 iterations for clip encryption (1..1000000)")
flag.Parse()
if pbkdf2Iter < 1 || pbkdf2Iter > frame.MaxPBKDF2Iter {
log.Fatalf("pbkdf2-iters must be in [1, %d], got %d", frame.MaxPBKDF2Iter, pbkdf2Iter)
}
if motionPercent < 0 || motionPercent > 100 {
log.Fatalf("motion-percent must be in [0, 100], got %d", motionPercent)
}
frame.SetDefaultPBKDF2Iter(pbkdf2Iter)
tok := authToken
if tok == "" {
tok = os.Getenv("OKO_AUTH_TOKEN")
}
if tok == "" {
log.Fatal("authentication required: set -auth-token or OKO_AUTH_TOKEN")
}
frame.SetAuthToken(tok)
// Load every classifier once at startup instead of per frame.
classifiers, err := frame.LoadClassifiers([]string{classifierGlob})
if err != nil {
log.Fatalf("LoadClassifiers: %v", err)
}
defer func() {
for i := range classifiers {
classifiers[i].Close()
}
}()
log.Printf("Loaded %d classifier(s)", len(classifiers))
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
clipChan := make(chan frame.Clip, clipChanCap)
sendoffChan := make(chan frame.Clip, 8)
connSem := make(chan struct{}, connLimit)
ln, err := net.Listen("tcp", listenAddr)
if err != nil {
log.Fatalf("Listen %s: %v", listenAddr, err)
}
log.Printf("Listening on %s", listenAddr)
// Accept loop
var acceptWg sync.WaitGroup
rejected := frame.NewMalformedAttempts()
go func() {
<-ctx.Done()
ln.Close()
}()
go func() {
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
log.Printf("Accept error: %v", err)
continue
}
select {
case connSem <- struct{}{}:
default:
log.Printf("Rejecting connection from %s: too many concurrent connections", conn.RemoteAddr())
conn.Close()
continue
}
acceptWg.Add(1)
go func(c net.Conn) {
defer func() { <-connSem }()
defer acceptWg.Done()
frame.HandleClipConn(c, rejected, func(clp frame.Clip) {
clipChan <- clp
})
}(conn)
}
}()
// Bounded workers: motion-gate → classify → sendoffChan
var classifyWg sync.WaitGroup
classifyWg.Add(workerCount)
for w := 0; w < workerCount; w++ {
go func() {
defer classifyWg.Done()
for clp := range clipChan {
classifyClip(clp, sendoffChan, classifiers, motionPercent, passphrase)
}
}()
}
// Sendoff: forward processed clips to the coordinator with retry
var sendoffWg sync.WaitGroup
sendoffWg.Add(1)
go func() {
defer sendoffWg.Done()
for clp := range sendoffChan {
if err := clp.SendClipRetry(sendoffAddr, 6); err != nil {
log.Printf("Sendoff to %s failed, clip dropped: %v", sendoffAddr, err)
}
}
}()
<-ctx.Done()
log.Println("Shutting down...")
ln.Close()
if !frame.WaitGroupTimeout(&acceptWg, shutdownGrace) {
log.Println("Inbound handlers did not drain; closing channel anyway")
}
close(clipChan)
if !frame.WaitGroupTimeout(&classifyWg, shutdownGrace) {
log.Println("Classifiers still busy; closing sendoff anyway")
}
close(sendoffChan)
if !frame.WaitGroupTimeout(&sendoffWg, shutdownGrace) {
log.Println("Sendoff still busy (coordinator retry?); exiting anyway")
}
if n := rejected.Total(); n > 0 {
log.Printf("Rejected %d malformed clips from the network", n)
}
log.Println("Exiting")
}
// classifyClip decrypts, motion-gates (skipping recompute when mofin already
// did it), classifies, re-encrypts, and forwards the clip.
func classifyClip(clp frame.Clip, sendoff chan<- frame.Clip, classifiers []frame.Classifier, motionPercent int, passphrase string) {
if passphrase != "" {
if err := clp.Decrypt(passphrase); err != nil {
log.Printf("Decrypt error: %v", err)
return
}
}
if len(clp.Comparisons) == 0 {
if err := clp.CountChangedPixels(30); err != nil {
log.Printf("CountChangedPixels: %v", err)
return
}
}
highest := clp.GetHighestMotion()
if motionPercent > 0 && highest < motionPercent {
log.Printf("Dropped clip with motion=%d%% (below %d%% threshold)", highest, motionPercent)
return
}
frames := clp.Sublimate()
clp.Detections = make([]map[string][]frame.Detection, len(frames))
for i, frm := range frames {
frm.DetectClassifiers(classifiers)
clp.Detections[i] = frm.Detections
}
if passphrase != "" {
if err := clp.Encrypt(passphrase); err != nil {
log.Printf("Encrypt error: %v", err)
return
}
}
hasDetection := false
for _, dets := range clp.Detections {
if len(dets) > 0 {
hasDetection = true
break
}
}
log.Printf("Forwarding clip with motion=%d%% (detections: %v)", highest, hasDetection)
sendoff <- clp
}
+7
View File
@@ -0,0 +1,7 @@
# Terp
Terp (short for interpreter) is an image classifier tuned to identify the contents of an image. The image itself is unchanged, but the medtadata has the detections tagged on.
# TODO
- Per-frame classification, workgroup of N threads keeps it parallel. Passed through pipeline, stages are variable and determined by the number of classifiers available.
- Go/No-Go on a per-clip basis for generalized detection of larger classifications
- Motion is NOT detected at this stage. Motion detection is handled on an earlier stage of the pipeline.
Executable
BIN
View File
Binary file not shown.
+175
View File
@@ -0,0 +1,175 @@
package main
import (
"testing"
"time"
"frame"
"gocv.io/x/gocv"
)
func makeMotionClip(t *testing.T, fill0, fill1 float64) frame.Clip {
t.Helper()
mat := gocv.NewMatWithSize(4, 4, gocv.MatTypeCV8UC1)
defer mat.Close()
mat.SetTo(gocv.NewScalar(fill0, 0, 0, 0))
bytes0 := mat.ToBytes()
mat.SetTo(gocv.NewScalar(fill1, 0, 0, 0))
bytes1 := mat.ToBytes()
return frame.Clip{
PixelMats: [][]byte{bytes0, bytes1},
Width: 4,
Height: 4,
Types: gocv.MatTypeCV8UC1,
Channels: 1,
Guids: [][]byte{{1}, {2}},
Timestamps: []uint64{0, 1},
}
}
func TestClassifyClip_MotionGateDropsBelowThreshold(t *testing.T) {
clip := makeMotionClip(t, 0, 0)
sendoff := make(chan frame.Clip, 1)
classifyClip(clip, sendoff, nil, 50, "")
select {
case <-sendoff:
t.Error("clip should have been dropped by motion gate (0% motion, threshold 50%)")
default:
}
}
func TestClassifyClip_MotionGatePassesForwardsWithoutDetections(t *testing.T) {
clip := makeMotionClip(t, 0, 255)
sendoff := make(chan frame.Clip, 1)
classifyClip(clip, sendoff, nil, 0, "")
select {
case <-sendoff:
case <-time.After(5 * time.Second):
t.Error("motion clip with no detections should still be forwarded")
}
}
func TestClassifyClip_NoClassifiersForwards(t *testing.T) {
clip := makeMotionClip(t, 0, 255)
sendoff := make(chan frame.Clip, 1)
classifyClip(clip, sendoff, []frame.Classifier{}, 0, "")
select {
case <-sendoff:
case <-time.After(5 * time.Second):
t.Error("motion clip with empty classifier list should still be forwarded")
}
}
func TestClassifyClip_WithPassphrase(t *testing.T) {
mat := gocv.NewMatWithSize(4, 4, gocv.MatTypeCV8UC1)
defer mat.Close()
mat.SetTo(gocv.NewScalar(100, 0, 0, 0))
plainBytes := mat.ToBytes()
f := frame.Frame{
PixelBytes: plainBytes,
Width: 4,
Height: 4,
GocvImageType: gocv.MatTypeCV8UC1,
Channels: 1,
Guid: []byte{1},
Timestamp: 0,
}
if err := f.Encrypt("test-pass"); err != nil {
t.Fatalf("encrypt: %v", err)
}
encryptedBytes := make([]byte, len(f.PixelBytes))
copy(encryptedBytes, f.PixelBytes)
f2 := frame.Frame{
PixelBytes: plainBytes,
Width: 4,
Height: 4,
GocvImageType: gocv.MatTypeCV8UC1,
Channels: 1,
Guid: []byte{2},
Timestamp: 1,
}
if err := f2.Encrypt("test-pass"); err != nil {
t.Fatalf("encrypt: %v", err)
}
encryptedBytes2 := make([]byte, len(f2.PixelBytes))
copy(encryptedBytes2, f2.PixelBytes)
clip := frame.Clip{
PixelMats: [][]byte{encryptedBytes, encryptedBytes2},
Width: 4,
Height: 4,
Types: gocv.MatTypeCV8UC1,
Channels: 1,
Guids: [][]byte{{1}, {2}},
Timestamps: []uint64{0, 1},
}
sendoff := make(chan frame.Clip, 1)
classifyClip(clip, sendoff, nil, 0, "test-pass")
select {
case <-sendoff:
case <-time.After(5 * time.Second):
t.Error("motion clip with passphrase should decrypt and forward even without detections")
}
}
func TestClassifyClip_ZeroMotionForwardsAtZeroThreshold(t *testing.T) {
clip := makeMotionClip(t, 0, 0)
sendoff := make(chan frame.Clip, 1)
classifyClip(clip, sendoff, nil, 0, "")
select {
case <-sendoff:
case <-time.After(5 * time.Second):
t.Error("with threshold 0 the motion gate does not drop; clip should forward")
}
}
func TestClassifyClip_PassphraseWrongKey(t *testing.T) {
f := frame.Frame{
PixelBytes: []byte{1, 2, 3, 4},
Width: 2,
Height: 2,
GocvImageType: gocv.MatTypeCV8UC1,
Channels: 1,
Guid: []byte{1},
Timestamp: 0,
}
if err := f.Encrypt("real-key"); err != nil {
t.Fatalf("encrypt: %v", err)
}
clip := frame.Clip{
PixelMats: [][]byte{f.PixelBytes},
Width: 2,
Height: 2,
Types: gocv.MatTypeCV8UC1,
Channels: 1,
Guids: [][]byte{{1}},
Timestamps: []uint64{0},
}
sendoff := make(chan frame.Clip, 1)
classifyClip(clip, sendoff, nil, 0, "wrong-key")
select {
case <-sendoff:
t.Error("clip with wrong passphrase should not be forwarded")
default:
}
}