mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-10-02 23:45:08 -04:00
da0ba713c1ecc65f2b19d39fc0bd4ef5656de708
27
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
428c048bac |
feat: measure the audio level on demand, with a meter in the editor
Reverts the previous commit's always-on measurement. Decoding audio for every monitor that has it spends CPU on a number almost nothing reads, which is the wrong trade even though it did solve the chicken and egg of picking a threshold without ever seeing a level. Measure when something is actually going to use the reading instead: - AudioDetection is on, as before, so nothing changes for a monitor that scores on audio; or - somebody asked. SharedData gains audio_level_until, a wall clock second the capture thread keeps measuring up to. The monitor editor's new level meter pushes it forward while it is on screen and the measurement lapses a few seconds after the page is left, so nothing has to send a stop and a crashed browser cannot leave a monitor decoding forever. When the reading stops being wanted the decoder is released and the published level and peak are cleared, so a stale number is not left looking current and an old peak does not land on the next frame row written. audio_level_until is carved out of analysis_pad rather than appended, so SharedData stays 888 bytes and no existing offset moves; the static_asserts, Memory.pm and Monitor.php are updated together and all three now agree the field is at +880. The meter itself is on the audio settings, shown whether or not AudioDetection is checked, because the level is what you need in order to choose a threshold. It draws the threshold currently in the input as a mark on the bar so a reading can be judged against it before saving, and a monitor whose zmc is not running reads "no reading" rather than a confident 0, which would be indistinguishable from silence. Frames.AudioLevel is therefore 0 again on monitors that do not score on audio. That is what the graph already treats as "no audio data", so it draws no line rather than a flat one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME |
||
|
|
4619258293 |
feat: measure the audio level whatever AudioDetection is set to
Gating the measurement on AudioDetection made the graph useless for the job it is most wanted for. AudioThreshold is a per-device number -- the floor on one camera's mic is nothing like another's -- so it has to be measured before it can be set, but nothing was measured until it was already set. Enabling detection with a guessed threshold to find out what the real one should be is backwards. The level is now read for every monitor with decodable audio. AudioDetection governs only whether crossing the threshold contributes a score, which is what the setting is named for. shared_data->audio_alarm stays 0 when it is off, so nothing downstream changes for a monitor that does not want audio alarms. Nothing here depends on Analysing either. The measurement is in Monitor::Capture, which runs on whatever Analysing is set to, and frame rows come from Event::AddFrame, which a continuously recording monitor reaches through the RECORDING_ALWAYS path with motion detection off. So a monitor that only records continuously still gets levels on its rows. Since Monitor::Capture retries Open on every audio packet until it succeeds, and that now happens for every monitor with audio rather than the handful with detection on, AudioDetector remembers a codec it has already failed to find a decoder for. Without it a stream ZoneMinder cannot decode logs a warning at the audio packet rate for as long as the monitor runs. A reconnect bringing a different codec is still tried. The cost is one audio decode per monitor with audio, where before it was one per monitor with detection enabled. That is small next to the video path, but it is not nothing on a box with many cameras. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME |
||
|
|
4ea307a1cf |
feat: graph motion score and audio level under the event video
The cue strip under the video showed one flat red band per alarm period. It tried to encode the score as a bar height, 'height: '+frame.Score+'px', but never could: the frames ajax did not return Score, so every bar got 'height: undefinedpx' and fell back to the stylesheet's height: 100%. So the motion level it looks like it is drawing has never actually been drawn. Replace it with a line graph of both series over the length of the event. The alarm periods stay, as a pale wash behind the lines, so nothing that was readable before is lost. The two series do not share a vertical scale. Audio level is 0-100 by construction, but a motion score is a sum over zones with no upper bound, so pinning both to 0-100 would flatten the audio line against the floor on any event scoring above 100. Each is scaled to its own maximum, and hovering reads out the exact values, which is what the numbers are wanted for. An event whose rows are all zero -- recorded before the column existed, or by a monitor with AudioDetection off -- draws no audio line at all, rather than a flat line claiming silence was measured. The readout goes into the existing #indicator, which already tracks the mouse across the whole progress bar, instead of a second tooltip competing for the same pixels. The geometry lives in web/js/LevelGraph.js so it can be tested without a DOM, and so the polyline and the hover readout cannot disagree about where a given second sits. The bar grows from 1.25em to fit the graph, which needed two consequential CSS changes: #indicator now spans the taller bar, and .progressBox drops from 0.66 opacity to 0.25, because at full strength the played part of the graph is unreadable behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME |
||
|
|
40c4cd3c5d |
fix: run reverse playback at the rate the user picked
changeRate's reverse branch did not use the selected rate as the reverse speed. It looked it up as rates[rates.indexOf(-rate)-1]/100, stepping one entry further down the shared rate list, so every reverse rate ran a notch too slow: -1/2x played at 1/4x and -16x at 10x. -1/4x was worse than slow, because one step below 25 in that list is 0, so revSpeed came out 0 and the video sat still while the ui claimed it was rewinding. The rate the user picked is the speed, so use it. Leaving reverse through the dropdown also leaked the rewind interval, which only pauseClicked and vjsPlay ever stopped. Picking a forward rate after a reverse one left it running, so it went on dragging currentTime backwards and resetting playbackRate to 0 on every tick while the player was supposedly running forwards. The teardown is now stopRewind(), split out of stopFastRev() because stopFastRev rewrites the rate select to 1x, which would undo the choice changeRate is in the middle of applying. stopFastRev no longer reads the rate back out of the player to decide what to put in the select and the cookie, for the same reason streamFastFwd stopped doing it: videojs can defer the set until the tech is ready, so the getter still answers with the rate we just left. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME |
||
|
|
ef361dc3ab |
fix: stop the event playback rate buttons stepping off the end of the rate list
Clicking fast forward once too many at 16x threw out of video.js:
TypeError: HTMLMediaElement.playbackRate setter: Value being assigned is
not a finite floating-point value.
playbackRate@video.min.js
streamFastFwd@..._views_js_event-....js
streamFastFwd stepped the shared rate list by indexing it directly,
rates[rates.indexOf(current)+1]. At the top of the list that is rates[15],
undefined, and undefined/100 is NaN, which Firefox refuses outright.
The guard meant to prevent this ran after the assignment rather than before
it, and read the rate back from the player to decide, so it could only
disable the button once the bad value had already been sent. It was reachable
in normal use because streamPlay() re-enables the button whatever rate we are
at, so play-then-fast-forward at 16x throws every time; picking 16x from the
rate dropdown gets there too, since changeRate does not touch button state.
indexOf also answers -1 for a rate that is not in the list, and -1+1 indexes
rates[0], which is -1600: stepping forwards from an unlisted rate asked for
16x reverse.
streamFastRev had the same fault at the other end. rates[0-1] is undefined, so
revSpeed became NaN and every tick of the rewind interval then handed
currentTime a NaN.
Both now go through stepRate, which snaps an unlisted rate to the nearest
listed one and returns null rather than walking off either end, so the caller
disables the button and leaves the player alone instead of assigning
something it cannot use. streamFastFwd also sets the dropdown and cookie from
the rate it just asked for rather than reading it back, because the stack in
the report shows videojs deferring the set until the tech is ready, at which
point the getter still answers with the old rate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
|
||
|
|
f36645dfd9 |
fix: point the audioMotion-analyzer install instructions at the ES module
The library is AGPL-3.0-or-later so it is not shipped, and the admin installs it at skins/<skin>/assets/audioMotion-analyzer/src/audioMotion-analyzer.js. The instructions for doing that had drifted from what the code loads: - help.txt and the OPTIONS_WHATTODISPLAY help gave the bare https://cdn.jsdelivr.net/npm/audiomotion-analyzer@X.X.X URL, which resolves to the package's "main" entry, the minified UMD bundle dist/index.js. The install path is the package's src/ ES module, so the URL needs the explicit /src/audioMotion-analyzer.js suffix. Same for the download links in the AudioMotionVersionNotInstalled and AudioMotionVersionWrongVersion messages. - The install path was written as /skins/MySkin/..., a placeholder that does not correspond to any skin. - RequiresAudioMotionEnabled named only the file, not where it goes. - assets/version documented every other asset in that directory but not this one, leaving no explanation for the otherwise empty directory. help.txt no longer restates the required version, so 4.5.4 stays declared only by SUPPORTED_AUDIO_MOTION_ANALYZER_VERSION as intended, and assets/version points at that constant rather than duplicating it. Add tests/js/audiomotion-paths.test.js to hold the PHP feature probe, the dynamic import, help.txt and both lang catalogues to the same path, the same download URL and the same version. Also gitignore the installed library so a local install is not committed back into a GPL-2.0 tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME |
||
|
|
599627a940 |
fix: stop the cycle timer leaking an interval on every restore fixes #5135
cycleStart() took a fresh setInterval id straight into cycleIntervalId without clearing what was already there. Once overwritten the old id is unrecoverable, so the orphan keeps calling nextCycleView every second and cyclePause() can only ever stop the last one armed. Several callers reach cycleStart() with no cyclePause() in between: the play button, the are-you-still-watching modal closing, and startPage(). That last one is the routine path - it runs from visibilitychange, from resume and from pageshow, and a restore fires more than one of those, so a tab coming back while cycling was active armed two. The monitor restart in the same function is protected, since it nulls prevStateStarted on the way through, but the cycle branch below it never cleared prevStateCycle. Clear the interval at the top of cycleStart(), which makes every caller safe whatever order they arrive in, and clear prevStateCycle in startPage() the way prevStateStarted already is. cycle.js has the same shape in its own cycleStart() behind a play button, so it gets the same guard. Tests in tests/js/watch-cycle-interval.test.js drive watch.js under stubbed timers and count what is left running: two starts leave one interval, a pause after three starts leaves none, and a second startPage() does not re-arm. Three of the four fail against the unfixed file. Full JS suite passes, ESLint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM |
||
|
|
da99928235 |
fix: revalidate on every resume rather than trusting a time window
The staleness window was unsound. calculateAuthHash() keys the hash to the clock hour it was minted in and getAuthUser() accepts the last ZM_AUTH_HASH_TTL hourly buckets, so a hash dies at the top of an hour rather than at some age. generateAuthHash() then serves the cached one until it is half a TTL old, so what arrives can already be nearly spent: on the defaults a hash minted at 10:59 is still handed out at 11:58 and is refused at 12:00. A client stamping that arrival as fresh for an hour skips the probe until 12:58 and restarts its streams on a dead hash - the exact failure this was written to prevent. No fixed window is safe, because the remaining life of a hash we hold can be anything down to zero, and AUTH_STALE_MS also ignored the configured ZM_AUTH_HASH_TTL. So drop AUTH_STALE_MS, authIsStale() and authFreshAt, and have whenAuthFresh() revalidate. The one case that can still skip the probe is having no hash at all - authentication off, or a relay form that does not use one - where there is nothing that can expire and nothing a probe would report. revalidateAuth() already shares one request between concurrent callers, so a resume that wakes several of these still costs a single probe, and that is what the montage code did unconditionally before any of this. refreshTablesPendingVisibility() now returns as soon as it finds nothing was deferred. It is bound on every classic page including the unauthenticated ones, and the version before this ran the whole auth path on an empty queue, so merely becoming visible could fire a probe with no work behind it. Tests: two authIsStale cases removed with the function, two whenAuthFresh cases added - a probe is sent and the callback held until it answers, and no probe is sent when there is no hash. Reintroducing a fast path fails the first. Full JS suite green, ESLint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM |
||
|
|
4c878eeebe |
fix: send the auth revalidation probe without a credential
The probe went out as zmAuth.appendTo(...), carrying the very hash it existed to replace. zm_authenticate_request() resolves a request against exactly one source: a non-empty auth= in the URL enters the ZM_AUTH_HASH_LOGINS branch (on by default), and when getAuthUser() rejects it the chain has already been taken, so the userFromSession() arm below it never runs. A live session cookie then authenticates as nobody. Past ZM_AUTH_HASH_TTL - a tab hidden longer than two hours on the defaults, which is exactly the case this change is for - the probe was therefore the one request guaranteed to fail, and its failure is read as 'login', so the user was bounced to the login page with a perfectly good session. That is worse than the 403s in the log this set out to remove. Send the probe bare. The session cookie is what answers, which is the question being asked: who am I, and what is my current hash? That also makes the failure handling mean what its comment claimed. A rejection now really is a dead session rather than a dead hash, so redirecting to login on it is right - and both 401 and 403 reach it, which the comment now says. Also correct the AUTH_STALE_MS comment: authIsStale() is a strict comparison, so a credential confirmed exactly AUTH_STALE_MS ago is still fresh, as the test asserts. Tests: tests/js/auth-helpers.test.js, 51 passed (4 new for revalidateAuth, covering the bare probe, shared in-flight request, callbacks surviving a transient failure, and login on a rejected session). Reverting the probe to the credentialed form fails two of them. refs #5093 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nr76CednxtDt2nPuq6WrbL |
||
|
|
1d01c966d4 |
refactor: track credential age instead of hidden time, fold montage in
whenAuthFresh() gated on how long the tab had been hidden, which only covers one of the ways the page stops hearing about the credential. montage's idle timeout is another: hitting ZM_WEB_VIEWING_TIMEOUT stops every monitor, and their status polls with them, while the tab stays visible the whole time. The Are You Still Watching modal can then sit there for hours, so the auth hash baked into the monitor srcs is just as dead as after an overnight sleep, and a hidden-time gate would wave it straight through. Track the age of the credential itself instead. ZMAuth.update() stamps it whenever a reply carries auth_relay or auth - even when the value is unchanged, since the server has still just confirmed it - and revalidateAuth() stamps it too, so authentication being off doesn't leave every caller revalidating once an hour forever. authHiddenTooLong(hiddenAt, now) becomes authIsStale(freshAt, now); onAuthVisible() no longer keeps a timestamp. That subsumes montage's refreshAuthAndStartMonitors(), which duplicated revalidateAuth()'s navBar probe and fired a second one on every refocus. Both call sites are now whenAuthFresh(startVisibleMonitors), which shares the in-flight probe rather than racing it. Tested: node tests/js/auth-helpers.test.js (47 passed), node tests/js/table-helpers.test.js (8 passed), npx eslint on the changed files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkE45gkMjnkTiJiUySbe7V |
||
|
|
c83a15e048 |
fix: refresh auth hash before restarting streams after a long tab hide
Hidden tabs have their timers throttled, and frozen/slept ones stopped outright, so nothing refreshes the credential while the tab is in the background. The server rotates the auth hash at half of AUTH_HASH_TTL (generateAuthHash()), so the hash baked into a stream <img> src or a deferred table url is usually dead by the time the tab is refocused. Every restarted stream and table poll then fires a request that 403s and logs an auth error before anything gets around to revalidating. Record when the tab goes hidden and treat the credential as stale once more than an hour has passed. Add whenAuthFresh(cb), which runs cb straight away after a short alt-tab but queues it behind a revalidation after a long sleep, so nothing makes an authenticated request on the expired hash. revalidateAuth() now queues callbacks rather than dropping them when a probe is already in flight, clears the hidden timestamp only once a reply actually arrives, and still runs the queued callbacks on a transient failure - a network blip is no reason to leave the page's streams stopped. A 401 still redirects to login and drops the queue. Gate the two visibility-driven callers on it: watch.js startPage() and table-helpers.js refreshTablesPendingVisibility(). montage.js already refreshes auth before restarting its monitors. Tested: node tests/js/auth-helpers.test.js (48 passed), node tests/js/table-helpers.test.js (8 passed), npx eslint on the changed files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkE45gkMjnkTiJiUySbe7V |
||
|
|
1361f93804 |
fix: send the initial mode=paused CMD_PLAY that streamCommand was dropping
select_zms() has three ways out, and two of them send a stream command before the tail of the function sets started. streamCommand() drops anything sent while that is false, so such a branch reports success having sent nothing and the picture sits on its last keepalive frame. The resume branch was fixed on this branch already. The branch below it, for a page rendered with mode=paused, has the same shape and was missed. With auth on it needed a still-valid hash to reach, so it was intermittent; with auth off, where there is no hash that can go stale, the srcAuthCurrent change on this branch makes it the path every initial load takes. Set started there too. Add tests/js/monitorstream-resume.test.js, which asserts what reaches the wire rather than which branch ran: the resume and initial-paused paths each send exactly one CMD_PLAY on the connkey they are supposed to address, resuming leaves src and connkey alone, and a stale auth hash still rebuilds and quits the process the old connkey addressed. Removing the one-line fix fails the initial-paused case and leaves the other three passing. Full JS suite passes, ESLint clean on both files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM |
||
|
|
741de2d2e4 |
fix: finish the review items on the zone object size tool
The four points from the review on #5076 that the first round left open. Persist a ratio as the number that will be read back. zone.php reloads these cookies through validInt(), which strips everything that is not a digit, so 70.5 was stored and returned as 705 and 1e2 as 12, while the live calculation took the same field through parseFloat and used it as typed. The ratio therefore changed meaning on reload. Round and range-check before storing, and write the result into the field so what is shown, what is calculated and what is stored are one number. Clear the tool when the page's Reset is pressed. Reset restores every field from initialValues and redraws the polygon, so the measurement Undo would revert is already gone, but undoSnapshot, lastBox, the highlighting and the rectangle all survived it. The handler is wrapped rather than joined by a listener because ours has to run first: resetChanges calls updateArea, which now re-derives. Re-derive when the zone area changes. The thresholds are stored as percentages of the zone, so moving, adding or removing a vertex changes what they mean in pixels and the saved numbers stop describing the object that was measured. updateArea is the one funnel those edits go through, so wrap it and re-run the derivation from the box still on screen. apply() only reaches updateAllPixelDisplays, so there is no way back into updateArea from there. Draw with pointer events, and give the tool a keyboard-operable equivalent. pointerdown/move/up with pointer capture replaces the mouse-only gesture, which touch and pen could not drive at all, and touch-action is suppressed while armed so the browser does not claim the drag for a scroll. pointercancel ends the drag too, since a pointer taken away by the browser never sends pointerup. For the keyboard there is now an object size in capture pixels beside the ratios: it derives through the same path, draws the same box centred in the frame, and raises the same filter warning. A finished drag writes its size back into those fields, so the two ways in stay in step. Also re-check measurability at pointerdown. The type listener added in the first round covers the select, but applyPreset writes Type directly and fires no change event, so a preset that makes the zone Inactive or Privacy left the tool armed and apply() would re-enable the fields applyZoneType had disabled. tests/js/zone-object-size.test.js covers the two new pure functions: 46 assertions pass. ESLint clean on the module and the test; php -l clean on zone.php, zone.js.php and en_gb.php. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b82ee8b7dd | Merge branch 'master' into zone-object-size-helper | ||
|
|
461adbbd20 | fix: show the events table error and clear loading on both paths refs #3301 | ||
|
|
eff68f4103 | fix: stop the events table loading forever when the query fails refs #3301 | ||
|
|
eb0ad029f5 |
fix: correct zone rectangle measurement edge cases refs #5075
From review on #5076: - Arming the tool now discards the previous measurement, so a drag after re-arming snapshots the fields as they are then. Before, edits made between sessions were ignored by the next drag and reverted by undo. - Judge the filter warning per axis, comparing width against FilterX and height against FilterY. Comparing both against the larger of the two flagged shapes the filter keeps, and an object that exactly fits the kernel survives it. - Decode a chosen still before showing it. The MIME type alone lets a corrupt file pause the stream and display a broken image. - Stand down when the zone type changes to Inactive or Privacy while the tool is armed, rather than re-enabling fields applyZoneType disabled. - Move the undo snapshot on when a managed field is edited by hand, so undo no longer reverts work done after the measurement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019YkuGif56kchpHBHK3d65A |
||
|
|
cbd68bb323 |
fix: bind the EventStream status poll to the connkey it belongs to
Montage review created 86 stream connkeys in five minutes while only two of them were ever polled, and each stream painted a single frame before being replaced about 1.3s later. The status poll for a connkey whose zms had exited returned result=Error, recover() restarted the stream under a new connkey, and the reply already queued for the old one arrived afterwards and restarted the new stream too. Every restart painted its first frame, which reset consecutiveErrors and recoveryDelay in img.onload, so the backoff never grew and the attempt limit was never reached. - Tag each ajax/stream.php exchange with the connkey it was issued for and ignore a reply, success or failure, once that connkey has been replaced. stream.php does not echo the connkey back, so the client tracks it. - Restart only on reason=no_socket, the one class that means zms is gone, matching streamErrorIsFatal() in MonitorStream.js. The helper is duplicated as EventStream.errorIsFatal() because montagereview.php does not load MonitorStream.js. - Start the poll timer next to the src that created the connkey instead of in img.onload, which is not a dependable per-restart signal for a multipart/x-mixed-replace img, and let the first query wait a full interval so a stream that is still starting is not read as a missing socket. - Reset the recovery counters only on a successful status reply, so the exponential backoff and the attempt limit both work. Also stop appending a second '?' to UrlToZMS, which already carries '?monitor=N', so the logs no longer show monitor=24?source=event. tests/js/eventstream-connkey.test.js covers the stale-reply rules, the fatal classification and the URL separator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019YEe1M3PWVMqYWihUeAa5N |
||
|
|
420f2b92be |
feat: derive zone detection settings from a drawn rectangle
Adds a measuring tool to the zone editor. Drag a box around the smallest object that should trigger an alarm and the detection thresholds are derived from its area, replacing the guesswork of picking pixel counts by hand. Sizing follows docs/userguide/definezone.rst. [J] gives the rule directly, a minimum Alarmed Area of "25% of the area that an object of interest takes up in the Zone region", along with a worked example - 30% of the zone giving 7.5% - which the tests assert. The filtered and blob minimums step down from there by 70% and 86%, the ratios every shipped Blobs preset uses whatever its sensitivity, so the steps are a property of the pipeline rather than a sensitivity dial. All three are editable in a table beside the zone settings. Each stage is measured against the one it is drawn from: filtered pixels are a subset of alarmed ones, and a blob is built from pixels that survived filtering. Taking the blob threshold as a share of the filtered count rather than the alarmed count makes MinBlob <= MinFilter <= MinAlarm hold by construction, so no clamp is needed to keep validateForm satisfied whatever ratios are entered. Thresholds are computed as pixel counts and converted to percentages once, at the point of storage. Deriving the filtered and blob values from an already-rounded alarm percentage rounded twice, which for a 1104px box in a 100000px zone put the filtered minimum a whole step out. CheckMethod is set to Blobs, since MinBlobPixels is the only threshold tested per blob - the other two are zone-wide sums that several small movers can reach together. applyCheckMethod() is called explicitly because assigning to a select does not fire onchange, and the blob fields would otherwise stay disabled and never submit. Every Max is left blank. MaxAlarmPixels, MaxFilterPixels and MaxBlobs set overload_count, blinding the zone for OverloadFrames frames, and exceeding MaxBlobPixels deletes the blob outright. One rectangle cannot imply a ceiling when the same object is far larger close to the camera. MinPixelThreshold and FilterX/Y are camera properties rather than object sizes, so they are kept whenever already set, from a preset or by hand, and filled from the documentation's defaults only when blank. Interaction: - thresholds track the box live during the drag, so nothing on the mousemove path blocks - the tool stays armed and each new box replaces the last, all measured against the state before the first, so undo and highlighting stay anchored - mouseup redraws at the release point before measuring it, so the visible rectangle is always the one the numbers came from - a label on the box reports its size in capture pixels - rewritten fields are marked in the khaki the point rows already use, and the marks clear when a preset or a hand edit takes over - a still image can stand in for the live view, read locally as a data URL and drawn in exactly the box the stream occupies so coordinates still map Ratio defaults live only in zone.php, which reads the cookies and renders each field with its stored value as the player selector does with zmZonePlayer. Reuses maxX/maxY, constrainValue, streamCmdPause/Play, getCookie/setCookie and validInt rather than reimplementing them, and shares the existing highlight colour and Editing stroke width. zone.js is unchanged. Tests: tests/js/zone-object-size.test.js, 35 assertions, node+assert matching the existing tests/js pattern. Browser interaction is not covered. |
||
|
|
edb4d7c202 |
fix: stop an applied filter coming up empty on the events list fixes #5026
Applying a named filter and clicking LIST MATCHES landed on Events showing "No matching records found" until a manual refresh. Two independent defects produce that, and both are fixed here. Stored filter selections overriding the applied filter ------------------------------------------------------ The zmFilter_* cookies remember what was last selected in console, montage and montagereview. They are a convenience for the default, filter-less page, but they were also applied on top of a filter the request had specified, silently widening or narrowing it: the applied filter was ANDed with a date range left over from an earlier visit to another view, so it matched nothing. Refreshing appeared to fix it because the bar's reset control blanks those inputs. The cause was a layering one. Filter::simple_widget() refilled any empty term value from that term's cookie as it rendered the input, so it overrode the value addTerm() had been given, from below the layer that knows what the request asked for. An earlier attempt to fix this in events.php could not hold for that reason: it blanked the values and they were refilled during rendering, and ajaxRequest() sends the live inputs rather than the URL. Filter now resolves nothing. It renders the value it was handed, and the six cookie fallbacks are gone; terms keep their cookie name so edits still persist client-side. The callers that build those terms decide instead, next to getFilterSelection(), which already resolved request-before-cookie this way. Each resolves its value into a local before the addTerm() calls, testing $use_stored in the same statement that reads the cookie, so the rule is visible at the point it applies. montagereview needs a window to draw whatever happens, so with a filter present it derives one from the filter's own terms and otherwise defaults to the last hour, rather than reaching for the stored window; its Notes term no longer seeds from a cookie inside the branch that already has an explicit filter. Tables never retrying a request skipped while hidden ---------------------------------------------------- The table views skip their ajax request while the page is hidden so a background tab does not poll. Bootstrap-table calls that function on init as well as on refresh, though, and a skipped request was never re-issued: the table rendered "No matching records found" over a result it never asked for, with nothing to bring it back. A page can be hidden for the whole of its load - opened in a background tab, restored, or behind another window - and the same guard is in seven views: events, console, log, frames, reports, snapshots and watch. deferTableRequestWhileHidden() skips the request as before and records the table, so it is refreshed the moment the page becomes visible. The queue is drained before refreshing, because refresh() calls the ajax function synchronously and would otherwise re-add a table that is still hidden. Tests ----- tests/audit-filter-cookies.php checks both halves of the first rule: that Filter resolves nothing, and that every stored-selection read in a caller tests $use_stored. It works a statement at a time, joining continuation lines, since a value and its guard often span a line break. Anything wider is too coarse: the enclosing block holds other guarded reads and would mask one that lost its own. tests/js/table-helpers.test.js covers the deferral queue, including a table re-deferred during its own refresh. Verified against a live instance with the stale cookies still set: a "last hour" named filter queried 0 of 4 matching events before and 12 of 12 after; the filter-less page still restores the stored date range; and a table deferred while hidden repaints on becoming visible. |
||
|
|
ea4f0c5f9a |
fix: return empty from zmAuth.applyTo for a blank stream src
applyTo appended the relay to an empty src, yielding a bare '?auth=...'. Callers that treat a blank src as "nothing to load" would then set it on an <img>, where it resolves against the current page and loads the surrounding HTML as an image. montagereview's loadImage2Monitor is one such caller. Introduced with ZMAuth; the code it replaced returned the src untouched. |
||
|
|
12cb7d9601 |
fix: don't clear onerror/onload on the go2rtc video-stream element in kill() refs #5025
MonitorStream.kill() unconditionally did `stream.onerror = null` and
`stream.onload = null` on whatever element the monitor was using. That was
written for the zms <img>, whose onerror/onload are inherited event-handler
accessors.
With go2rtc the element is <video-stream>, where onerror is a method on
VideoRTC.prototype (video-rtc.js) overridden by VideoStream (video-stream.js).
Assigning null there finds a writable data property on the prototype chain and
so creates an *own* property on the instance, shadowing the method for as long
as the element lives. replaceDOMElement() returns the same node when the tag
already matches, so select_go2rtc() handed the poisoned element back on the
next start, and the listener VideoRTC.onconnect() registers,
this.ws.addEventListener('error', (ev) => this.onerror(ev));
threw "TypeError: this.onerror is not a function" on the next websocket
failure. Any kill()-then-start() path reached it: switching monitors on watch,
the stop/play buttons, montage viewport handling. It also meant the restart
that VideoStream.onerror performs was silently dead after a kill().
Guard the assignments on the element actually being an IMG.
Add tests/js/monitorstream-kill.test.js, which evaluates the real
MonitorStream.js in a vm context and checks that kill() leaves a prototype
onerror callable on a <video-stream>, adds no own onerror/onload to it, and
still clears both on an <img>.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXtwyhzssj24Xwjmx8EA2z
|
||
|
|
40129a5564 |
refactor: replace the auth_hash/auth_relay globals with one ZMAuth credential
The page kept two copies of the same secret: auth_relay, the query fragment every AJAX call is authenticated with, and auth_hash, the bare hash stamped into stream <img> URLs. Different responses updated different copies, so they could drift, and a drifted auth_hash produced stream URLs that zms rejects. ZMAuth stores only the relay and derives the hash from it, so the two cannot disagree. Its helpers cover the four shapes the call sites used: zmAuth.hash derived, '' under the plain/none relay forms zmAuth.update(data) absorb the auth fields of any response zmAuth.appendTo(url) authenticate a url, no-op when auth is off zmAuth.applyTo(src) point a stream url at the current credential appendTo also removes the `x ? '&'+x : ''` guard repeated at every call site, some of which had omitted it and emitted a dangling '?'. Migrates all call sites across web/js and the classic skin, and drops both globals from skin.js.php. Tests: tests/js/auth-helpers.test.js, 44 passing. |
||
|
|
ae8c7db488 |
fix: stop orphaning zms when a stream command fails refs #5029
getStreamCmdResponse() responded to every ajax/stream.php failure the same way: mint a fresh connkey and reload the img src. ajaxError() returns HTTP 200 with result=Error, so these arrive in jQuery's done() rather than fail(), and all twelve error paths in stream.php took that branch. Only one of them means zms is gone. For the rest the process is still running and streaming, and replacing the connkey makes it unaddressable: CMD_STOP, CMD_QUIT and mode=single all then go to the new key, so nothing can reach the old process and only SIGPIPE can stop it, which we know is unreliable. That is why the reports of lingering zms after switching monitors were unaffected by changes to what the stop path sends. The timeout path made this routine rather than rare. On select() expiry ajaxError is commented out, so the script carries on to socket_recvfrom() on a now non-blocking socket. That returns false, and false == 0 under switch's loose comparison, so a merely slow zms was reported as 'No data to read from socket' and torn down. stream.php now classifies each failure as no_socket, timeout, transient or invalid, and sends it as 'reason'. The client restarts the stream only for no_socket. A missing reason is still treated as fatal, so a php that predates this keeps the old behaviour. Before replacing the connkey the client now sends CMD_QUIT to the old one, so the process we are about to lose track of is asked to exit. That is deliberately not routed through streamCommand(): it must name its target explicitly, since this.connKey is about to change, and its response must not feed back into getStreamCmdResponse(), or a QUIT that also failed would re-enter the error path and loop. ajaxError() takes the classification as a third argument, named $reason because $code is already the HTTP status, and only includes it when set, so the other 131 callers are unaffected. Tests: tests/js covers the fatal/non-fatal decision including the no-reason fallback, tests/php pins the classification mapping and the switch(false) semantics the timeout branch depends on. Both verified to fail when the behaviour is reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ce1474667a |
fix: stop montage zms <img> reconnect storm on stale auth hash
A live multipart (mode=jpeg) stream <img> whose baked auth hash expires past AUTH_HASH_TTL is reconnected by the browser itself, reusing the same src (same connkey, same dead hash). Every native reconnect returns 403, so once the capture daemon drops the stream the client storms zms with auth failures for hours. Observed in production: a single connkey retried 84 times over 2.5h, 880 failures on one monitor whose zmc was timing out, while monitors with healthy zmc showed only baseline hash-rollover noise. img_onerror only blanked the <img> src inside its async refresh callback, which never ran once authRefreshAttempts reached the cap. On give-up the stale src stayed live and the browser kept native-retrying it, which is the storm. Blank src synchronously at the top of img_onerror so the browser's retry loop stops immediately, and reconnect with a fresh connkey (the zms process behind the old connkey has exited) after fetching a fresh hash. Extract the src rewrite into a pure rebuildStreamSrc() helper in auth-helpers.js with unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a55d167b91 |
feat: refresh auth hash on tab resume, redirect to login on dead session refs #4748
When a console or stream tab is backgrounded or the machine sleeps past the auth hash TTL, the baked-in auth= on nph-zms <img> URLs expires. The browser keeps reconnecting with the stale hash, producing a burst of 403s from zms while the session-backed page still renders. Detect the tab becoming visible again (visibilitychange/pageshow) and probe auth once against the navBar status endpoint before letting streams reconnect: on success refresh the global auth_hash and repaint; on a dead session (401) go straight to login instead of retrying. Route the navBar poll, console table query, and per-stream error paths through a shared decision so 401/403 ends in a single login redirect rather than a retry storm. Put the auth functions (goToLogin, revalidateAuth, onAuthVisible) and the pure authFailureAction/loginRedirectUrl helpers in web/js/auth-helpers.js as named globals; skin.js only wires the visibility listeners. Node unit tests cover the pure helpers (tests/js/auth-helpers.test.js). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c4073c964c |
feat: encoder parameter templates with editor + REST API closes #4778 closes #4802
Adds a curated, per-encoder parameter-template library to ZoneMinder:
- Monitor edit page: a new Template row above the EncoderParameters
textarea offers per-encoder templates (Balanced / Archival / Low
Power / Low CPU). Apply merges the template's params into the
textarea, preserving user-only keys. Advisory lint flags option
keys that aren't recognised for the selected encoder. Switching
encoders offers a same-name template on the new encoder via a
native confirm.
- Options page: a new Encoder Templates tab with full CRUD —
list / edit / copy / delete — backed by a new CakePHP REST API
at /api/encoder_templates.
- Storage: a new EncoderTemplates DB table seeded with 14 shipped
defaults across libx264 / libx265 / h264_nvenc / hevc_nvenc /
h264_vaapi / hevc_vaapi. The table is mutable; ZM upgrades do not
re-seed user-edited rows.
- valid_keys (the lint allow-list) stays in PHP code as ffmpeg
vocabulary, not user data.
- Default params explicitly include pix_fmt to avoid the yuvj420p
HEVC HW-decode rejection issue we hit earlier.
No C++ change. The textarea content is parsed by the existing
av_dict_parse_string call in src/zm_videostore.cpp.
version.txt -> 1.39.6.
Specs: docs/superpowers/specs/2026-05-0{1,2}-*.md
Plans: docs/superpowers/plans/2026-05-0{1,2}-*.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|