Commit Graph

6551 Commits

Author SHA1 Message Date
SteveGilvarry
45099e53a7 fix: address review feedback on camera buffer overflow fix
- zm_libvnc_camera.cpp resize(): return FALSE and log if av_malloc fails,
  rather than returning TRUE with a null frameBuffer (libVNC would then
  write into it and crash). Matters more now that size_t sizing can
  request large allocations for server-advertised dimensions.
- zm_libvnc_camera.cpp: compute the buffer sizes in the scale Debug() in
  size_t with %zu, so the logged sizes can't overflow int and match the
  values passed to SWScale::Convert.
- zm_libvlc_camera: widen LibvlcPrivateData::bufferSize to size_t and
  compute it in size_t in PrimeCapture, so the allocation itself can't
  overflow. Image::Assign now passes the same stored bufferSize used for
  the allocation, so the read size can't exceed the buffer. Widen the
  compare loop index to size_t to match.

Verified: both translation units compile with -Werror -fsyntax-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 07:54:15 +10:00
SteveGilvarry
286cb90883 fix: compute camera frame buffer sizes in size_t to avoid int overflow
The frame buffer size calculations in the libVNC and libVLC camera
backends multiplied 16/32-bit operands and only widened the result to
size_t afterwards, so the multiplication itself could overflow before
the conversion. Flagged by CodeQL cpp/integer-multiplication-cast-to-long
(alerts 282, 145, 85). The VNC framebuffer dimensions are advertised by
the remote server (uint16_t), making the inputs externally influenced.

Cast the first operand to size_t so each product is computed in size_t:
- zm_libvnc_camera.cpp: SwScale::Convert src size (framebufferWidth *
  framebufferHeight * 4) and dest size (width * height * colours)
- zm_libvnc_camera.cpp: resize() bufferSize, changed from int to size_t
  (not converted to a larger type, so CodeQL did not flag it, but the
  same overflow applied); Debug format updated to %zu
- zm_libvlc_camera.cpp: Image::Assign size (width * height * mBpp)

Verified: both translation units compile with -Werror -fsyntax-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:55:06 +10:00
Isaac Connor
bc15782474 fix: skip final stream sleep when zm_terminate is already set
Avoid sleeping up to MAX_SLEEP (500ms) at the end of runStream when
termination has been requested, so zms shuts down more promptly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:11:51 -04:00
Isaac Connor
a36375585c refactor: make zm_terminate a std::atomic<bool>
zm_terminate is written from signal handlers and read from daemon loops.
A plain bool is undefined to access from a signal handler and may be hoisted
out of a loop by the optimizer. std::atomic<bool> is lock-free on supported
platforms and matches the already-atomic per-object terminate_ flags. Update
the three Debug() vararg sites to call .load() since atomics can't pass
through ...

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:11:51 -04:00
Isaac Connor
1e76424db1 Merge branch 'onvif-wsse-created-race' 2026-06-14 09:15:01 -05:00
Isaac Connor
a04cd7a432 fix: use utf8mb4 for C++ DB connection so Unicode monitor names log intact refs #4785
The connection charset was "utf8", MySQL's alias for 3-byte utf8mb3, while
Monitors.Name and the DB default are utf8mb4. On a utf8mb3 connection any
4-byte UTF-8 character is read back as '?' and, when embedded in a log message
written to the Logs table, raises ERROR 1366 and truncates the value. Names
with such characters lost their multibyte portion in logs, keeping only the
ASCII tail.

Connect with utf8mb4, falling back to utf8 with a warning if the server
rejects it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:06:37 -04:00
Isaac Connor
55bbec4c55 Merge pull request #4924 from SteveGilvarry/3816-db-ssl-verify-server-cert
feat: add ZM_DB_SSL_VERIFY_SERVER_CERT option (portable across MySQL/MariaDB)
2026-06-14 09:39:36 -04:00
SteveGilvarry
8801c42064 fix: address review feedback on DB SSL verify option
- API (database.php.default): only set the PDO verify flag when SSL is
  actually configured (ZM_DB_SSL_CA_CERT set), matching the web/Perl/C++
  layers. Previously a fresh install's default (1) would set the flag on a
  non-SSL connection, since the CakePHP datasource merges 'flags' uncondi-
  tionally.
- Both PHP layers: cast to string and trim before parsing the value, and use
  strict in_array, to avoid type-juggling and stray-whitespace edge cases.
- zm_db.cpp: use my_bool (not char) for the MYSQL_OPT_SSL_VERIFY_SERVER_CERT
  fallback argument, the type libmysqlclient expects. That branch only
  compiles on older clients without MYSQL_OPT_SSL_MODE, where my_bool exists.

refs #3816
2026-06-14 15:57:09 +10:00
SteveGilvarry
e60bdc67b2 feat: add ZM_DB_SSL_VERIFY_SERVER_CERT option (portable across MySQL/MariaDB)
Add a ZM_DB_SSL_VERIFY_SERVER_CERT setting so a database connection that uses
ZM_DB_SSL_CA_CERT can talk to a server with a self-signed or otherwise
non-matching certificate. When enabled, verification is by identity (the cert
must chain to the CA and its CN/SAN must match ZM_DB_HOST), consistent across
the C++ daemons, the PHP web interface, the CakePHP API and the Perl scripts.

This re-does the reverted #3817. That PR broke the build because it called
mysql_options(MYSQL_OPT_SSL_VERIFY_SERVER_CERT, ...), and that enum was removed
from the MySQL 8.0 C client in favour of MYSQL_OPT_SSL_MODE; it also passed a
c_str() where a my_bool* was expected, and referenced the PHP constant
unconditionally (fatal on PHP 8 for an upgraded install whose zm.conf predates
the option).

The option that controls server-cert verification differs by client library and
the symbols are enum values, not macros, so CMake feature-detects them by
compiling:
  - HAVE_MYSQL_OPT_SSL_MODE  (MySQL 5.7.11+/8.0, MariaDB Connector/C 3.1+)
  - HAVE_MYSQL_OPT_SSL_VERIFY_SERVER_CERT  (older MariaDB/MySQL)
zm_db.cpp uses SSL_MODE_VERIFY_IDENTITY / SSL_MODE_REQUIRED when the former is
available, else falls back to the latter with a proper my_bool.

Value handling is three-way in every layer: a truthy value verifies, a false-y
value (0/false/no/off) skips verification, and an empty/unset value leaves the
client default in place so existing installs are unchanged on upgrade. PHP, the
API datasource (via PDO flags) and the Perl DSN are all guarded with defined()
checks. Fresh installs default to 1.

Documents the full ZM_DB_* connection and SSL settings, including the hostname
verification gotcha when connecting by IP, in docs/userguide/configfiles.rst.

refs #3816
2026-06-14 13:20:00 +10:00
Isaac Connor
312ce1d676 fix: pin ONVIF WS-Security Timestamp and UsernameToken Created together
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>
2026-06-13 21:12:49 -05:00
Isaac Connor
2a61595335 Merge pull request #4915 from SteveGilvarry/3399-passthrough-flip-displaymatrix
fix: handle flips in passthrough orientation, fix uninitialized display matrix (fixes #3399)
2026-06-13 10:21:16 -04:00
Isaac Connor
5e0a0eeaee Merge pull request #4909 from SteveGilvarry/fix/rotate-plane-segfault
fix: corrupted scaled/converted output for rotated monitors (SWScale buffer alignment)
2026-06-13 10:20:56 -04:00
Isaac Connor
3b633a281b fix: Rotate/Flip honor borrowed source linesize; size Flip dest for 32-aligned planes
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>
2026-06-13 09:35:40 -04:00
SteveGilvarry
10add95dad fix: handle flips in passthrough orientation and fix uninitialized matrix
In passthrough mode the orientation was written as a display-matrix side
data plus the legacy rotate tag, but only rotations were handled; flips
(FLIP_HORI/FLIP_VERT) fell to an "Unsupported Orientation" warning. The
displaymatrix was av_malloc'd and only written in the rotation branches,
so a flipped passthrough monitor attached an uninitialized matrix as side
data — garbage rotation metadata in the MP4.

Initialise the matrix to identity (av_display_rotation_set(.,0)) so it is
always fully written, and express flips with av_display_matrix_flip so
rotation and flips are both carried losslessly in the display matrix with
no decode/re-encode. The legacy rotate tag only expresses rotation, so it
no longer warns for flips.

Note: the rotation component of the display matrix is honored by HTML5
<video>, but the reflection component may not be in all browsers; ZM's web
event player may need a CSS transform companion to render flips mirrored.
Desktop players (VLC/QuickTime) and the file metadata are correct.

refs #3399

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:51:31 +10:00
SteveGilvarry
7e15324385 fix: corrupted scaled/converted output for rotated monitors — SWScale guessed buffer alignment from width
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>
2026-06-13 10:19:09 +10:00
Isaac Connor
3eb0792b57 Merge pull request #4900 from SteveGilvarry/fix/rotate-plane-segfault
fix: rotate/flip segfault from AV_CEIL_RSHIFT on unsigned dimensions
2026-06-12 13:42:57 -04:00
Isaac Connor
c103c40337 Merge branch 'master' of github.com:ZoneMinder/zoneminder 2026-06-11 08:47:17 -04:00
Isaac Connor
0d93742b97 fix: delete FifoSources before RemoveSession on reconnect failure
When monitor->ShmValid() returns false and the reconnect attempt fails,
the cleanup path erased the video_source/audio_source entries from their
maps without deleting the FifoSource objects, then called RemoveSession
on the MediaSession. Two problems:

1. ZoneMinderFifoSource is only stopped/joined in its destructor. erase()
   on a raw pointer leaks the object and leaves its read_thread_ and
   write_thread_ running.

2. The xop::H264Source / H265Source / AV1Source is owned by the
   MediaSession and gets destroyed inside RemoveSession. The orphaned
   FifoSource still holds a raw m_h264Source pointer set via
   setH264Source(); the next SPS NAL parsed by the still-running
   ReadRun thread calls H264Source::SetSPS on freed memory and crashes
   in std::vector::assign.

Stack of the observed crash:
  H264Source::SetSPS                          H264Source.h:34
  H264_ZoneMinderFifoSource::splitFrames      zm_rtsp_server_fifo_h264_source.cpp:53
  ZoneMinderFifoSource::getNextFrame          zm_rtsp_server_fifo_source.cpp:265
  ZoneMinderFifoSource::ReadRun               zm_rtsp_server_fifo_source.cpp:59

Mirror the correct order already used by the "monitor went away" path
above (lines 188-197) and by the shutdown path (lines 362-375): delete
the FifoSources first so their dtors set stop_ and join the threads,
then RemoveSession can safely destroy the H264Source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 08:47:13 -04:00
Isaac Connor
dc7d89bea8 fix: bound ONVIF SOAP timeouts below zmdc 30s kill window
The ONVIF event poller runs in its own thread (Run -> WaitForMessage ->
PullMessages). On shutdown ~ONVIF() joins that thread, but the join could
block past zmdc's 30s SIGTERM->SIGKILL window because two timeouts were
effectively unbounded:

- pull_timeout_seconds was only clamped to < 60s, so the camera could hold
  the long-poll open that long.
- gSOAP connect/recv/send timeouts were 0 (infinite), so a hung camera
  blocked recv() forever.

Either case let thread_.join() exceed 30s, causing zmdc to SIGKILL zmc.

Set the SOAP socket timeouts to 25s and clamp pull_timeout to 20s, keeping
the invariant pull_timeout (<=20) < socket_timeout (25) < zmdc window (30):
a quiet long-poll still returns normally, a hung connection aborts within
25s, and the worst-case join finishes with margin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:11:23 -04:00
SteveGilvarry
ee366cdf78 fix: rotate/flip segfault from AV_CEIL_RSHIFT on unsigned dimensions
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>
2026-06-11 19:42:52 +10:00
Isaac Connor
37cfb64922 Merge pull request #4788 from ZoneMinder/fix/avpixelformat-respin
fix: deprecate ZM_COLOUR for AVPixelFormat (resubmit with planar/SHM fixes)
2026-06-06 12:45:03 -04:00
Isaac Connor
9687faac67 refactor: remove dead FfmpegCamera::imagePixFormat field
The field was assigned in three branches of the constructor (always to
the same value as Camera::pixelFormat) and never read anywhere. Unlike
LocalCamera, FfmpegCamera doesn't run a sws_scale step at the Camera
layer — libavcodec hands frames to the pipeline directly — so there's
no separate capture-vs-image format distinction to track.

Drop the redundant assignments and the member declaration. The
canonical camera-side format is now exclusively Camera::pixelFormat
(via Camera::PixelFormat()).

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 12:29:35 -04:00
Isaac Connor
b486c0cd6b fix: include <libavutil/pixdesc.h> in zm_pixformat.h (PR #4788)
zm_get_pix_fmt_name() calls av_get_pix_fmt_name() which is declared in
<libavutil/pixdesc.h>. The header previously got away with relying on
transitive includes via zm_ffmpeg.h, but tests/zm_pixformat.cpp (and
any other consumer that includes only zm_pixformat.h) would fail to
compile. Add the explicit include so the header is self-contained.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 11:49:30 -04:00
Isaac Connor
2b55ed45d1 fix: nullptr-safe pix fmt name in Colourise/Deinterlace_4Field error paths (PR #4788)
- Image::Colourise sizing-failure Error: av_image_get_buffer_size /
  av_image_get_linesize return negative for AV_PIX_FMT_NONE and
  unrecognised formats, so this branch fires exactly when
  av_get_pix_fmt_name(p_req_pixfmt) can return nullptr. Switched to
  zm_get_pix_fmt_name.
- Image::Deinterlace_4Field fallback Panic: same issue — fires when
  imagePixFormat is outside our dispatch set, where av_get_pix_fmt_name
  may return nullptr. Switched to zm_get_pix_fmt_name.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 10:01:13 -04:00
Isaac Connor
965936eaf8 fix: pass delta8_* fn ptrs directly; zm_get_pix_fmt_name in Delta panic (PR #4788)
- Image::Delta: pass delta8_bgr/rgb/argb/abgr/bgra/rgba/gray8 directly to
  delta_row() instead of dereferencing with *. They're already function
  pointers; the deref is redundant and would be undefined behaviour if
  any pointer were null (Initialise() sets them, but the explicit *
  obscures the intent and adds nothing).
- Switched the Delta fallback Panic from av_get_pix_fmt_name() to the
  nullptr-safe zm_get_pix_fmt_name() wrapper. The Panic fires exactly
  when imagePixFormat is unrecognised, where the raw function would
  return nullptr.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 21:26:55 -04:00
Isaac Connor
12d864e772 fix: Deinterlace_Discard OOB write on odd heights (PR #4788)
The y += 2 loop ran while `y < height`, but copied even row y into odd
row y+1. For odd image heights, the final iteration had y == height-1
and wrote to row height — past the end of the image buffer in all three
colour branches (GRAY8/YUV-Y plane, RGB24, RGB32).

Stop one short of the last row (y < height - 1) so y+1 stays in
bounds; the orphan last row in odd-height images is left untouched
(consistent with the (even, odd) pairing this discard pass is doing).
Also added a guard for height < 2 to keep the unsigned `height - 1`
calculation from wrapping.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 20:10:39 -04:00
Isaac Connor
20f95ccde9 Merge branch 'master' of github.com:ZoneMinder/zoneminder 2026-06-04 09:46:11 -04:00
Isaac Connor
6ecf51061a fix: more actionable error messages in GetImage/getSnapshot/WriteBuffer (PR #4788)
- Monitor::GetImage and Monitor::getSnapshot bounds-check branches were
  logging "Image Buffer has not been allocated", but by the time control
  reaches them the empty-vector check has already passed — the buffer IS
  allocated, the index is just out of range. Replaced with explicit
  "index N out of range (image_buffer.size() = M)" so debugging starts
  from the right place.
- Image::WriteBuffer unsupported-format error only printed `colours`,
  but the mapping in zm_pixformat_from_colours depends on both `colours`
  and `subpixelorder`, and planar formats legitimately have colours=1.
  Print both fields so a bad (colours, subpixelorder) pair is
  immediately identifiable.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 22:12:26 -04:00
Isaac Connor
9e19c6689c fix: Image(AVFrame*) ctor delegates to default ctor before AssignDirect (PR #4788)
The Image(const AVFrame*) constructor only initialised blend_buffer_
and blend_buffer_size_ before calling AssignDirect(frame). AssignDirect
now calls DumpImgBuffer() on both the failure (invalidate) and success
paths to release any previously-owned buffer — but in the
freshly-constructed Image, buffer/buffertype/allocation are
uninitialised, so DumpImgBuffer would read garbage and potentially
free an invalid pointer.

Use constructor delegation so Image() runs first (zero-initialises
every member, sets buffertype = DONTFREE), making the subsequent
DumpImgBuffer call a no-op on a known-empty Image.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 21:23:37 -04:00
Isaac Connor
de3638ec48 fix: Image::Assign(Image&) handle smaller-source linesize; explicit pixdesc.h (PR #4788)
- Image::Assign(const Image&): when the source linesize was *smaller*
  than the destination's (e.g. src came from a held-buffer ctor with a
  packed device stride while dst is FFALIGN'd to 32 from
  av_image_get_buffer_size), the function fell through to the flat
  memcpy of `size` bytes — over-reading the source by
  (linesize - image.linesize) * height bytes. Undefined behaviour;
  could crash or copy unrelated memory. Generalised the per-row branch
  to handle both directions: trigger whenever linesize != image.linesize
  and copy min(src, dst) bytes per row, so neither buffer is read or
  written past its capacity. Planar formats still refuse explicitly
  since per-row would lose chroma; the error message now reads "vs"
  instead of ">" to match the broader condition.
- Added an explicit `#include <libavutil/pixdesc.h>` in zm_image.cpp.
  The recent Rotate/Flip rewrites call av_pix_fmt_desc_get /
  av_pix_fmt_count_planes / use AVPixFmtDescriptor, which previously
  only compiled thanks to transitive includes from zm_ffmpeg.h →
  libavutil/imgutils.h. Make the dependency explicit.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 20:13:05 -04:00
Isaac Connor
bc88781a08 fix: Image::Assign(uint8_t*) handle packed source -> aligned dest (PR #4788)
The raw-buffer Assign overload previously validated buffer_size against
av_image_get_buffer_size(..., align=32) and then memcpy'd `size` bytes
flat. But callers feed it packed source buffers — Camera::ImageSize() is
now derived with align=1 to describe device buffers (V4L2 mmap, raw RTP,
etc.) — so for non-32-aligned widths:
  * the size check spuriously rejected valid packed source buffers, and
  * if relaxed, the flat memcpy would have read past the source.

Validate the source size against the packed (align=1) layout and the
destination size/allocation against the aligned (align=32) layout, then
copy plane-by-plane via av_image_copy. av_image_fill_arrays gives us
both layouts' per-plane pointers and strides, and av_image_copy walks
each plane with its own src/dst linesize so per-row padding never
over-reads the source.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 10:30:49 -04:00
Isaac Connor
62ff384855 fix: store event StartDateTime with second precision to match disk path refs #4870
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>
2026-06-03 08:42:10 -04:00
Isaac Connor
9bae26b990 fix: handle "no frames yet" sentinel before bounds check (PR #4788)
GetImage() and getSnapshot() both ran the bounds check
`(size_t)index >= image_buffer.size()` before testing the sentinel
`index == image_buffer_count`. When no frames have been written yet,
shared_data->last_write_index equals image_buffer_count, the initial
fallback assigns that to index, and image_buffer.size() also equals
image_buffer_count (the vector is sized to image_buffer_count). So the
bounds check fired first, logging the misleading
"Image Buffer has not been allocated" and returning -1/nullptr instead
of reaching the intended "no images in buffer" branch (return 0 /
nullptr with a proper message). Reorder both functions to handle the
sentinel after confirming image_buffer is non-empty, then do the bounds
check, then the actual slot read.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 07:45:55 -04:00
Isaac Connor
bc8be20e68 fix: use canonical imagePixFormat in Image::Assign(AVFrame*) (PR #4788)
The destination format for the same-format fast path was derived via the
deprecated AVPixFormat() getter, which re-derives from the legacy
(colours, subpixelorder) pair. If those fields ever drift out of sync
with imagePixFormat — or hit the GRAY8/YUV420P alias collision — the
fast path would compare against the wrong target and either fall
through to sws_scale unnecessarily or, worse, av_image_copy the wrong
layout. Use imagePixFormat directly and drop the manual
av_get_pix_fmt_name nullptr dance in the Debug log in favour of the
zm_get_pix_fmt_name wrapper.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 22:16:52 -04:00
Isaac Connor
bfe9c4ad4a fix: free previously-owned buffer in AssignDirect(AVFrame); nullptr-safe logs (PR #4788)
- Image::AssignDirect(const AVFrame*): both the invalidate() failure
  path and the success path overwrote buffer/buffertype without freeing
  any previously-owned buffer. If the Image currently owned a
  ZM_BUFTYPE_ZM/MALLOC/NEW allocation, that memory was leaked on every
  AssignDirect(frame) call. Added DumpImgBuffer() at the top of
  invalidate() and immediately before the success-path buffer
  reassignment; DumpBuffer is a no-op for DONTFREE buffers, so this is
  safe whether the Image previously owned its memory or wrapped a
  caller's buffer.
- Switched five remaining av_get_pix_fmt_name() calls flagged by review
  to zm_get_pix_fmt_name():
  * Monitor::GetAlarmImage warnings (zm_monitor.cpp:1432, 1438-1439)
  * Monitor::ReadShmFrame warnings (zm_monitor.cpp:3042, 3049-3050)
  * Camera ctor fallback Error (zm_camera.cpp:90)
  All can be reached with AV_PIX_FMT_NONE or unrecognised formats where
  av_get_pix_fmt_name returns nullptr, which is undefined when fed to %s.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 21:39:16 -04:00
Isaac Connor
8fc1992e4d fix: keep linked event open while source monitor is in ALERT state
Monitor::MonitorLink::score() previously returned 0 whenever the linked
monitor's state was not ALARM, even when its event was still open in
post-event countdown (ALERT). Combined with low AnalysisFPSLimit on the
source monitor (a common setup for HD/sub-stream pairs where the low-res
channel detects motion), state oscillates ALARM <-> ALERT around brief
motion pauses inside a single event. The link signal drops to 0 in those
gaps; the receiving monitor counts down its own PostEventCount and
closes the linked event prematurely — typically after a few seconds,
missing the remainder of a motion event that runs for minutes.

Return a sentinel score of 1 while the linked monitor is in ALERT so the
receiving event stays open. Do not return shared_data->last_frame_score
here: latching the score itself would re-apply a stale numeric to every
subsequent frame, inflating the accumulated score and biasing
snapshot.jpg selection. The sentinel keeps the open-event signal alive
without polluting the score channel.

Real-world evidence: monitor 28 (HD, links to monitor 36 sub-stream)
opened a 5.16-second linked event for a motion sequence that ran 300s
with two motion bursts 134s apart, missing the entire second burst.
With the latch, ALARM->ALERT transitions inside a single source event
are bridged. Long IDLE gaps (separate motion bursts) remain correctly
treated as separate triggers.
2026-06-01 16:31:25 -04:00
Isaac Connor
6d130ef42e fix: retain keyframe images so snapshot.jpg is written for pre-event frames refs #4843
When no event is active, Monitor::Analyse() frees packet->image to save
RAM. Sensible while idle, but those packets remain in the PacketQueue as
the pre-event ring buffer. When motion later opens an event, the event
inherits the first N pre-event packets and Event::AddFrame writes
snapshot.jpg from packet->image. With image == nullptr, the snapshot is
never written.

Retain the image on keyframes (~one per GOP) while idle. Non-keyframes
are still freed, so the bulk of the RAM saving stays. When an event
opens, the pre-event window now contains at least one packet with a
valid image, giving Event::AddFrame something to snapshot from.

Cost is one retained Image per GOP per monitor while idle.
2026-06-01 14:13:57 -04:00
Isaac Connor
eee9c0f4ac fix(packetqueue): destroy packets after releasing mutex; drop dead code
clear() was destroying ZMPackets while holding the queue mutex, unlike
queuePacket() and clearPackets() which defer destruction to a stack
vector that goes out of scope after the lock is released. ZMPacket
teardown frees Image, AVFrame, and AVPacket data and is expensive,
especially on PASSTHROUGH queues that may hold hundreds of MB. Doing it
under the mutex risked stalling capture and analysis threads during
shutdown or monitor reconfigure. Adopt the same deferred-destroy
pattern.

Also remove dead code identified in the audit:
- PacketQueue::unlock(ZMPacketLock*): defined but no callers; would
  UAF if anyone passed a non-heap pointer.
- monitor_ field and setMonitor(): set never read, no callers.
- analysis_it field: declared, never used (Monitor::analysis_it is
  the live one).
- get_stream_it(int) declaration: never defined.
- max_video_packet_count comment: clarify 0 means unlimited (the
  setter clamps negatives to 0 and the limit check is > 0).
2026-06-01 11:54:32 -04:00
Isaac Connor
90c6bc0b42 fix: guard SHM pixformat publish on assign failure (PR #4788)
- Monitor::WriteShmFrame: image_pixelformats[index] was being recorded
  even when image_buffer[index]->Assign(*capture_image) silently left
  the slot untouched (Assign returns void; held-buffer undersize and
  unknown src format both early-return without mutating the dest).
  Readers would then adopt the newly-published format while the slot
  still held the previous frame's bytes, producing garble. Check the
  dest's post-Assign PixFormat against the source's and only publish on
  match; warn and keep the previously-published format otherwise so
  readers continue interpreting the slot bytes correctly.
- Monitor::WriteAlarmImage: same fix for alarm_image — verify Assign
  adopted src.PixFormat() before publishing *alarm_image_pixelformat.
- Image::AVPixFormat(AVPixelFormat) setter: switched the four
  format-name logs to the nullptr-safe zm_get_pix_fmt_name wrapper.
  The first Error branch triggers exactly when new_pixelformat is
  unrecognised, and av_get_pix_fmt_name returns nullptr for those.
- monitor.php: removed the manual translate fallback for
  DeprecatedColoursSetting. web/includes/lang.php always merges
  en_gb.php as a fallback when the active language differs, so
  translate() never returns the bare key once en_gb.php defines it.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 08:24:42 -04:00
Isaac Connor
ec8d48aa51 fix: switch remaining av_get_pix_fmt_name calls in zm_ffmpeg_camera.cpp to zm_ wrapper
Two Debug log sites in the hwaccel selection loop still called
av_get_pix_fmt_name directly. AV_PIX_FMT_NONE is a real possibility for
config->pix_fmt during the loop (the search may hit the sentinel) and
for hw_pix_fmt before find_fmt_by_hw_type returns a match. Route both
through zm_get_pix_fmt_name so a nullptr return can't be passed to %s.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 09:31:34 -04:00
Isaac Connor
6765962737 fix: nullptr-safe pixfmt name; route GetImage/getSnapshot through ReadShmFrame (PR #4788)
- Added zm_get_pix_fmt_name() in zm_pixformat.h: av_get_pix_fmt_name()
  returns nullptr for unknown formats (including AV_PIX_FMT_NONE), and
  passing nullptr to %s is undefined and can crash. Wraps with an
  "unknown" fallback. Replaced the five raw uses flagged by review
  (RemoteCameraRtsp/FfmpegCamera/VncCamera/LocalCamera constructors and
  MonitorStream::sendFrame's Debug log) with the wrapper. Each can hit
  AV_PIX_FMT_NONE before the first frame or via an unexpected
  (colours, subpixelorder) pair.
- Monitor::GetImage and Monitor::getSnapshot now route the slot read
  through ReadShmFrame so the per-slot AVPixelFormat zmc recorded is
  adopted on image_buffer[index] before its bytes are interpreted.
  Without this, the JPEG encode in GetImage and the ZMPacket returned
  by getSnapshot would interpret SHM bytes using the placeholder format
  set at attach time and produce garbled output when the slot's actual
  format differs (the contract previously relied on callers calling
  ReadShmFrame themselves). getSnapshot/GetTimestamp drop const since
  ReadShmFrame mutates image_buffer[index]; callers in this codebase
  already use a non-const Monitor pointer.
- Monitor::connect: updated the misleading "+64 bytes reserved" comment
  to match the actual reservation of 63 + (alignof(AVPixelFormat) - 1).

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 09:22:09 -04:00
Isaac Connor
fb7b1d1c77 fix: SHM padding covers both alignment adjustments (PR #4788)
mem_size only reserved 64 bytes of slack for the 64-byte alignment of
shared_images, but the code subsequently rounds image_pixelformats up
to alignof(AVPixelFormat) too. In the worst case shared_images shifts
by 63 bytes and image_pixelformats shifts by alignof(AVPixelFormat)-1
more, which could push image_pixelformats / alarm_image_pixelformat
past the end of the mapped region. Reserve 63 + (alignof(AVPixelFormat)
- 1) bytes so both adjustments fit.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 08:27:12 -04:00
Isaac Connor
f794e260cc fix: per-row Overlay; align image_pixelformats SHM pointer (PR #4788)
- Image::Overlay (no-offset variant): rewrite all eight format branches
  to walk row-by-row using each image's own linesize. The previous
  linear walk with `buffer + size` as the end pointer drifted across
  rows whenever this->linesize != image.linesize (which can happen for
  images constructed via held-buffer ctors at a different stride) and
  also walked into the destination's chroma planes for planar YUV
  destinations whose `size` covers chroma. Inner loops bound by `width`
  per row in every branch, so per-row padding is left untouched.
- Monitor::connect: align the image_pixelformats SHM base address up
  to alignof(AVPixelFormat) before casting. shared_images +
  2*image_buffer_count*image_size can be misaligned when image_size
  isn't a multiple of alignof(AVPixelFormat) (e.g. GRAY8 with odd
  width sourced from camera->ImageSize() before the SHM upper-bound
  applies). An unaligned AVPixelFormat* is undefined behaviour on
  strict-alignment ISAs and slow even on x86. The +64-byte padding
  reserved in mem_size already covers the small shift.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 13:54:31 -04:00
Isaac Connor
36effc8bb5 fix: stride-aware Delta and Deinterlace_4Field (PR #4788)
The delta8_* and *_deinterlace_4field_* SIMD helpers process pixel runs
or 2D blocks without any concept of per-row stride. With the
FFALIGN(linesize, 32) layout, an Image's buffer may have per-row padding
(linesize > width*bpp), and feeding the helpers the raw buffer pointer
made them treat padding bytes as image data — wrong motion deltas and
visibly broken deinterlacing on non-32-aligned widths.

- Image::Delta: drive each delta8_* helper one row at a time, passing
  `width` pixels per call with `buffer + y*src_linesize`,
  `image.buffer + y*img_linesize`, and `pdiff + y*dst_linesize`. No
  copies; just a stride-aware caller-side loop.
- Image::Deinterlace_4Field: the 4-field helpers internally use the
  passed `width` as their row stride, so a per-row loop wouldn't
  preserve their inter-row processing. Pack both inputs into
  tightly-laid-out (linesize == width*bpp) temp buffers, run the
  helper, then copy only the data bytes back into the padded source
  buffer (padding stays intact). Fast path skips the copy when linesize
  already equals width*bpp on both images (the 32-aligned-width case).

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:35:32 -04:00
Isaac Connor
aa9884ee99 Merge branch 'master' into fix/avpixelformat-respin 2026-05-28 20:04:34 -04:00
Isaac Connor
2846970614 fix: PopulateFrame error handling + canonical pix fmt in Scale (PR #4788)
- Image::Assign(AVFrame*) fast path: PopulateFrame can fail
  (av_buffer_create / av_image_fill_arrays errors); calling av_image_copy
  on an unpopulated temp_frame is undefined. Check the return and bail
  out cleanly on failure.
- Image::Scale: use the canonical imagePixFormat directly instead of
  the deprecated AVPixFormat() getter (which re-derives via the legacy
  (colours, subpixelorder) pair and would hit the GRAY8/YUV420P alias
  collision if those drift).

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:38:18 -04:00
Isaac Connor
a5bf39d413 fix: keep DeColourise/AssignDirect(AVFrame) state self-consistent (PR #4788)
- Image::DeColourise: updating only imagePixFormat=GRAY8 left size still
  at the previous RGB sizing and linesize at the previous RGB stride.
  Downstream ops that allocate via size (Flip/Rotate) under-allocate,
  and row-stride loops that use linesize address the wrong rows. Now
  recomputes size and linesize from the new GRAY8 layout via
  av_image_get_buffer_size/av_image_get_linesize and calls
  update_function_pointers().
- Image::AssignDirect(AVFrame*): never called update_function_pointers()
  after the format change, so an Image that previously held a different
  format kept stale fptr_delta/fptr_blend/fptr_convert bindings and
  subsequent ops took the wrong optimized path. Added the call at the
  end of the success path.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:15:36 -04:00
Isaac Connor
133bb53c77 fix: Camera::linesize/imagesize back to tightly-packed (PR #4788)
Camera::linesize/imagesize was changed by the migration to FFALIGN(.,32)
and av_image_get_buffer_size(...,32) to keep SIMD-friendly strides. But
Camera describes the *device-side* buffer that capture paths copy from
(V4L2 mmap buffers, raw RTP frames, etc.). Those external buffers are
tightly packed at the driver/source stride, not 32-byte aligned, so the
inflated imagesize causes:
- LocalCamera::PrimeCapture's `pSize != imagesize` check
  (av_image_get_buffer_size(..., align=1) vs Camera::imagesize) to
  Fatal for any width that isn't a multiple of 32.
- Other raw-socket capture paths to pass an oversized imagesize as the
  source buffer size, risking out-of-bounds reads of driver-allocated
  buffers.

Revert Camera::linesize/imagesize to align=1 (tightly packed). Image
internal buffers and SHM slots independently apply 32-byte alignment
where they need it (Monitor::connect already takes
max(camera->ImageSize(), av_image_get_buffer_size(RGBA, w, h, 32)) for
the SHM slot, so the SIMD-aligned slot capacity is preserved).

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:42:38 -04:00
Isaac Connor
070704d92c fix: ceiling chroma dims in Rotate/Flip; clearer Assign error (PR #4788)
- Image::Rotate / Image::Flip: chroma plane dimensions were computed as
  `width >> log2_chroma_w` / `height >> log2_chroma_h`, which floors.
  For odd luma widths/heights the last chroma column/row was skipped,
  leaving U/V samples unrotated/unflipped on odd-sized frames. Use
  AV_CEIL_RSHIFT to match FFmpeg's plane dimension convention.
- Image::Assign(const Image&): the AV_PIX_FMT_NONE guard's error
  message said "unexpected colours per pixel" and printed image.colours,
  which is misleading — planar formats legitimately have colours==1.
  Report the real cause (missing AVPixelFormat metadata) plus the
  legacy (colours, subpixelorder) for context.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:15:39 -04:00
Isaac Connor
3592fbb2b4 fix: dest linesize in HighlightEdges, ceiling chroma dims in MaskPrivacy (PR #4788)
- Image::HighlightEdges: the RGB24 and RGB32 output branches addressed
  the destination buffer as `high_buff + ((y*linesize + lo_x) * 3|4)`,
  where `linesize` is the *source* (GRAY8) stride. For non-32-aligned
  widths the source GRAY8 stride and destination RGB24/RGB32 stride
  differ in their padding, so multiplying the source stride by 3 or 4
  doesn't yield the destination's row offset and writes spill past
  high_buff on the last row. Read the destination's linesize from
  high_image and use that for phigh. The neighbour-pixel lookups also
  used `p ± width`; switched to `p ± src_linesize` so we follow the
  actual source row stride. All three branches (GRAY8/RGB24/RGB32) now
  pick neighbours via src_linesize.
- Image::MaskPrivacy: chroma plane dimensions were `width/2` and
  `height/2` (truncating). For odd width/height the planar layout in
  AVFrame uses ceil(W/2) / ceil(H/2), so the last column or row of
  chroma was never neutralised, leaving a strip with the source's
  original hue along the right/bottom edge — defeats the privacy mask.
  Use (W+1)/2 and (H+1)/2; existing in-loop guards on the Y indices
  already clamp the odd-edge lookup to the bitmap bounds.

refs #4788

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:30:23 -04:00