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>
MonitorStream::processCommand computed buffer_level by dividing by
temp_image_buffer_count (and via MOD_ADD modulo) while only guarding on
playback_buffer. processCommand runs on the command_processor thread,
started in runStream before temp_image_buffer_count is assigned from
playback_buffer. In that window temp_image_buffer_count is still 0 while
playback_buffer is already > 0, so a command arriving then divided by
zero and raised SIGFPE, crashing nph-zms.
Extract the percentage math into MonitorStreamBufferLevel which returns 0
when the buffer count is not positive, and add Catch2 coverage for the
zero-count and wrap-around cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ONVIF PullMessages intermittently failed with ter:NotAuthorized (logged as
the misleading clock-drift error) every few thousand requests, then ZM tore
down a healthy subscription and re-subscribed. SOAP logs showed the trigger:
the failing request always had wsu:Timestamp/Created one second behind
UsernameToken/Created, while every successful request had them identical.
The cause is in set_credentials(): soap_wsse_add_Timestamp() and
soap_wsse_add_UsernameTokenDigest() each call time(NULL) on their own, so when
the two calls straddle a one-second boundary the two Created values diverge by
a second and Hikvision rejects the request. This is probabilistic, which is
why it hit roughly hourly per camera and constantly across a fleet.
Capture time(NULL) once, re-stamp the Timestamp Created/Expires from it, and
use soap_wsse_add_UsernameTokenDigest_at() so the token Created and its
password digest are pinned to the same instant. Both Created values are then
always identical.
Add tests/zm_onvif_wsse.cpp asserting the two Created values match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
get_y_image() wraps the decoder Y plane zero-copy but recorded
FFALIGN(width,32) as the Image stride instead of the frame's real
linesize[0]. For widths that are not a multiple of 32 the decoder packs
the plane tighter than FFALIGN, so:
- Image::Rotate/Flip re-derived the source stride via
av_image_fill_arrays(...,32) and read past the end of the borrowed
plane (and skewed Y-channel motion analysis, which reads the same
buffer).
- Image::Flip sized its destination from this->size, which is tight for a
borrowed plane and smaller than the 32-aligned layout the planes are
written at, overrunning the destination.
Record the frame's real linesize in get_y_image(); use the Image's own
linesize for the source plane-0 stride in Rotate/Flip; size Flip's
destination from av_image_get_buffer_size(...,32). All are no-ops for
self-consistent ZM-allocated (32-aligned) images.
tests/zm_image_linesize.cpp: Rotate 90/180/270 and Flip H/V over a tight,
non-32-aligned GRAY8 source verifying correct output.
refs #4788
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SWScale::Convert chose av_image_fill_arrays alignment by heuristic
(width % 32 ? 1 : 32) for both buffers. Image buffers are always laid
out align-32, so any width not divisible by 32 made Convert read luma
rows 16 bytes short and chroma planes from packed offsets: diagonal
shear plus garbage chroma. Rotating a monitor is what produces such
widths (1280x720 ROTATE_270 -> 720 wide, 3840x2160 ROTATE_90 -> 2160
wide, both % 32 == 16), so every scaled view and every re-encode of a
rotated monitor was corrupted while unrotated monitors (1280/2688/3840
all % 32 == 0) were untouched. The rotate/flip segfault fix exposed
this: before it, rotated planar frames crashed zmc before reaching
Scale.
Alignment is a fact about how a buffer was laid out, not something
derivable from dimensions. Convert now takes explicit in/out alignment:
Image::Scale passes 32/32, the videostore encode path mirrors
get_out_frame's allocation choice, libvnc passes 1 (packed VNC
framebuffer) and 32 (Image WriteBuffer). Remove the unused
SetDefaults/ConvertDefaults API rather than threading alignments
through dead code.
Tests: new Scale regression case on a 720x1280 YUV420P image
(column-banded luma, uniform chroma) fails before the fix exactly as
observed live (sheared rows, V plane reading 0) and passes after.
Full suite 84/84.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Image::Rotate and Image::Flip computed chroma plane dimensions with
AV_CEIL_RSHIFT(width, log2_chroma_w). The macro's runtime form is
-((-(a)) >> (b)), which relies on arithmetic right shift of a negative
value and is only valid for signed operands - FFmpeg always passes int.
Image::width/height are unsigned, so the negation wraps, the shift is
logical, and the result is 2^31 + ceil(w/2^b) instead of ceil(w/2^b):
for 1280x720 the chroma rotate received src_w=2147484288 and
src_h=2147484008 (captured in gdb), writing gigabytes out of bounds.
Effect: zmc's decoder thread segfaulted on the first decoded frame of
any monitor with a rotated or flipped orientation and a planar pixel
format - a monitor with decoding Always crash-loops.
Replace the macro at both sites with an explicit unsigned ceiling
shift helper and a comment documenting the trap.
Tests: new tests/zm_image.cpp covers Rotate 90/180/270 and Flip on
YUV420P with per-plane marker pixels, plus odd dimensions exercising
the chroma ceiling. The Rotate cases segfault before this fix (verified,
exit 139) and pass after. Full suite 85/85 via ctest. Live-verified on
a 1280x720 ROTATE_270 monitor with decoding Always: pre-fix crash
within seconds, post-fix 90s clean run under gdb.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
1764623999 is 2025-12-01 21:19:59 UTC, not 19:59:59. Pointed out by
Copilot review on PR #4871.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SystemTimePointToMysqlString appended ".%06d" microseconds to the string it
hands MySQL for the Events.StartDateTime datetime column. That column has no
fractional precision, and MySQL 8 ROUNDS a fractional value when storing it, so
a start_time of 23:59:59.5xx-.999999 local was promoted to 00:00:00 of the next
day. Event::SetPath() derives the on-disk day folder from to_time_t(start_time),
which truncates, so it landed on the previous day.
For continuous recording the event start is backdated to the preceding keyframe,
which for a section forced closed at local midnight falls just before midnight.
On MySQL 8 the DB row then recorded the next day while the files were written
under the previous day's folder, producing a permanent zmaudit path mismatch and
orphaned files when the event aged out (the purge path is built from
StartDateTime).
Format the value to whole seconds only so it matches to_time_t() used by
SetPath(), keeping the DB row and the disk folder on the same second regardless
of whether the DB engine rounds or truncates.
Add tests/zm_time.cpp covering the floor-not-round behaviour and consistency
with the to_time_t-derived path second.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
zmfilter and the web filter UI generated SQL like
to_days(E.StartDateTime) = to_days('2026-05-06 09:42:56')
which prevents MySQL from using the StartDateTime index, forcing a
full table scan. With many filter daemons against a large Events
table this saturates mysqld and makes the system unresponsive.
Rewrite the SQL generation in ZoneMinder::Filter (Perl) and
ZM\FilterTerm (PHP) so Date/StartDate/EndDate attrs emit range
expressions against the underlying datetime column:
E.StartDateTime >= '2026-05-06 00:00:00'
AND E.StartDateTime < '2026-05-07 00:00:00'
Covers =, !=, >, >=, <, <=, IS, IS NOT, IN, NOT IN, and the
CURDATE()/NOW() values (which use INTERVAL 1 DAY for the upper
bound). EXPLAIN now reports type=range on Events_StartDateTime_idx
where it previously reported type=ALL.
CurrentDate (the constant left-hand expression to_days(NOW()))
keeps its existing form since it does not touch the indexed column.
Add Perl and PHP unit tests under tests/perl/ and tests/php/
exercising the generated SQL across operators.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ParsePercentagePolygon clamped to [0, width] / [0, height], so a new
zone created at 100% produced pixel coords equal to the monitor
dimensions. The rasterizer requires hi_x < width and hi_y < height,
so the runtime then logged warnings like
"polygon hi_x (1920) >= image width (1920), clamping".
Clamp to width-1 / height-1 instead. Updates existing tests that
encoded the previous behavior.
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>
Eight Copilot comments from the second review pass:
1. monitor.php (#3): "Deprecated - will be auto-detected..." note next
to TargetColorspace bypassed translate(). Added DeprecatedColoursSetting
key to en_gb and routed the deprecation note through translate() so it
localises with the rest of the form.
2. tests/zm_pixformat.cpp (#4): zm_colours_from_pixformat / round-trip
tests didn't cover the new YUV422P/YUVJ422P entries (added by
02e6be6b4). Added explicit assertions in both test cases — bumps
pixformat coverage from 105 to 115 assertions.
3. zm_image.cpp WriteBuffer (#5): linesize and size were derived from
p_width * p_colours, which undercounts planar YUV* (where p_colours=1
via the GRAY8 alias collision but actual buffer needs ~1.5x/2x for
chroma). Use av_image_get_buffer_size and av_image_get_linesize for
the AVPixelFormat instead, with bail-out on either failing.
4. zm_image.cpp AssignDirect (#6, #7): av_image_get_buffer_size returns
int and can be negative; assigning that into unsigned size/allocation
wrapped to a huge value. Check the return first, treat negative as the
same "unsupported format" failure as zm_colours_from_pixformat
returning false, and reset size/allocation/linesize/pixels to 0
(alongside imagePixFormat=NONE/colours=0/subpixelorder=0) so the
Image is left in a single coherent invalid state instead of partially
stale.
5. zm_image.cpp Assign (#8): av_get_pix_fmt_name(format) can return
nullptr (e.g. AV_PIX_FMT_NONE / unknown); passing that into
Debug(..., "%s", ...) would segfault. Capture once with a fallback
string before logging.
6. zm_monitor.cpp Capture path (#9): same nullptr issue with two Debug
calls — capture native_fmt_name once with fallback.
7. zm_monitor.cpp can_passthrough comment (#10): comment claimed
YUVJ422P would be converted to YUV420P because Image drops chroma,
but can_passthrough now allows YUV422P/YUVJ422P passthrough since
02e6be6b4 added 4:2:2 support. Updated the comment to describe the
current behavior (full 4:2:0 + 4:2:2 planar passthrough plus GRAY8
and RGB24/32) so the code and the rationale agree.
Tests: 76 cases, 788 assertions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ZM_COLOUR_GRAY8, ZM_COLOUR_YUV420P, and ZM_COLOUR_YUVJ420P were all
defined to 1, making format identification via colours ambiguous.
LocalCamera misidentified YUV420P as GRAY8, causing V4L2 MJPEG cameras
to decode to grayscale via expensive sws_scale conversion.
Replace the legacy ZM_COLOUR_*/ZM_SUBPIX_ORDER_* integer pair with
AVPixelFormat as the single source of truth for pixel format dispatch:
- Add src/zm_pixformat.h with central format helpers:
zm_pixformat_from_colours, zm_colours_from_pixformat,
zm_bytes_per_pixel, zm_db_colours_to_pixformat, zm_is_rgb32,
zm_is_rgb24, zm_is_yuv420
- Add AVPixelFormat pixelFormat member + PixelFormat() accessor to Camera
- Add PixFormat() accessor to Image, delegate AVPixFormat methods
to shared helpers
- Migrate all ~100 format dispatch comparisons in zm_image.cpp,
zm_local_camera.cpp, zm_ffmpeg_camera.cpp, zm_remote_camera_rtsp.cpp,
zm_libvlc_camera.cpp, zm_libvnc_camera.cpp, zm_monitor.cpp,
zm_mpeg.cpp from colours/subpixelorder checks to imagePixFormat/
AVPixelFormat checks
- Deprecate GetFFMPEGPixelFormat, delegate to zm_pixformat_from_colours
- Fix DeColourise bug: imagePixFormat was not updated to GRAY8
- Deprecate ZM_COLOUR_* and ZM_SUBPIX_ORDER_* constants in zm_rgb.h
- Add deprecation notice on Monitor.Colours web UI dropdown
- Add 13 Catch2 test cases (105 assertions) for format mapping helpers
refs #4735
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The zone loader now ignores the Units DB field and detects the coordinate
format by checking for decimal points: decimal values are percentages,
integer-only values are legacy pixels. This fixes motion detection being
broken when zones had Units=Pixels but percentage coordinates (or vice
versa), which resulted in a ~99x99 pixel zone on a 2560x1440 monitor.
The PHP zone view now always forces Units=Percent when saving, since it
always works in percentage space. convertPixelPointsToPercent() now
returns bool to indicate whether conversion occurred.
Tests added for: truncation bug via atoi, correct percentage-to-pixel
conversion, auto-detect heuristic, and resolution independence.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Convert zone coordinates from absolute pixel values to percentages
(0.00-100.00) so zones automatically adapt when monitor resolution
changes. This eliminates the need to manually reconfigure zones after
resolution adjustments.
Changes:
- Add DB migration (zm_update-1.37.81.sql) to convert existing pixel
coords to percentages, recalculate area, and update Units default
- Add Zone::ParsePercentagePolygon() in C++ to parse percentage coords
and convert to pixels at runtime using monitor dimensions
- Backwards compat: C++ Zone::Load() checks Units column and uses old
pixel parser for legacy 'Pixels' zones
- Update PHP coordsToPoints/mapCoords/getPolyArea for float coords,
replace scanline area algorithm with shoelace formula
- Update JS zone editor to work in percentage coordinate space with
SVG viewBox "0 0 100 100" and non-scaling-stroke for consistent
line thickness
- Position zone SVG overlay inside imageFeed container via JS to align
with image only (not status bar)
- Support array of zone IDs in Monitor::getStreamHTML zones option
- Update monitor resize handler: percentage coords don't need rescaling,
only threshold pixel counts are adjusted
- Add 8 Catch2 unit tests for ParsePercentagePolygon
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When zones have Units='Percent' in the database but their Coords contain
pixel values (>100), ParsePercentagePolygon treats them as percentages,
causing wild scaling (e.g., 639% * 1920 / 100 = 12269) followed by
clamping to monitor bounds, producing degenerate full-frame zones.
Add a pre-check in Zone::Load that scans coordinate values before calling
ParsePercentagePolygon. If any value exceeds 100, log a warning and use
ParsePolygonString (pixel path) instead. Also add unit tests for both
ParsePolygonString and ParsePercentagePolygon.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move the ISO 8601 time formatting function to zm_time.cpp/h so it is
reusable and not duplicated. Remove the local copies from
zm_monitor_onvif.cpp (was static) and tests/zm_onvif_renewal.cpp
(was a copy for testing). Both now use the shared declaration from
zm_time.h.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cameras like Reolink send alarm=true but never send the corresponding
false, causing alarms to stick indefinitely. Use the TerminationTime
from PullMessagesResponse to auto-expire stale per-topic alarms.
- Add AlarmEntry struct with value and termination_time fields
- Extract TerminationTime from each PullMessagesResponse and attach
it to alarm entries; refresh on re-trigger so active alarms persist
- Sweep expired alarms after processing messages and on poll timeout
- Add expire_alarms option (default: true) to disable via onvif_options
- Fix TOCTOU race: remove unsynchronized alarms.empty() check before
acquiring mutex in the timeout sweep path
- Simplify SetNoteSet with C++17 structured bindings
- Add Catch2 tests for alarm expiry logic (mirrored struct to avoid
gSOAP header dependency)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add cleanup_subscription() helper method that properly unsubscribes
from ONVIF cameras with error handling. This prevents subscription
leaks when renewals fail or when restarting subscriptions.
Changes:
- Add cleanup_subscription() method to header and implementation
- Call cleanup on renewal failures in Renew()
- Call cleanup when start() detects existing soap context
- Improve destructor logging for unsubscribe failures
- Add test documentation for cleanup behavior
Refs: ONVIF subscription leak issue
Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com>
- Add subscription_termination_time and next_renewal_time tracking to ONVIF class
- Add update_renewal_times() helper method to parse and store termination times
- Initialize renewal tracking in constructor
- Capture TerminationTime from CreatePullPointSubscriptionResponse
- Update times after successful Renew operations
- Implement conditional renewal logic in WaitForMessage() to only renew 10 seconds before expiration
- Add unit tests for renewal timing logic
This reduces unnecessary ONVIF traffic by only renewing subscriptions when needed,
following ONVIF specification best practices.
Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com>
Using the Sutherland-Hodgman algorithms convex and concave subject polygons can be clipped
by convex clip polygons.
For now we only need clipping to rectangles (Box), so limit our implementation to that. If needed this can be
trivially extended to convex clip polygons (a check whether the clip polygon is actually convex has to be added).
If convex clip polygons are needed we have to switch to e.g the Vatti algorithm.