From 61b613c5e27b62e423c6936e3aeefe013984e241 Mon Sep 17 00:00:00 2001 From: keyldev Date: Wed, 22 Jul 2026 01:27:59 +0300 Subject: [PATCH 01/13] web: take a snapshot from the browser - GET /api/v1/cameras/{id}/snapshot returns a fresh JPEG, preferring Majestic's own /image.jpg and falling back to one short ffmpeg pull for cameras that don't have it - camera button on every tile and on the single-camera page; the modal fetches as a blob so a camera that doesn't answer says so instead of showing a broken image, and the download keeps a dated filename - guarded like live video: ViewLive plus the caller's camera subset - nothing is stored server-side yet; the shared, indexed snapshot library the desktop keeps is a separate slice --- src/OpenIPC.Viewer.Web.Client/src/api.ts | 5 + .../src/components/LiveTile.tsx | 8 + .../src/components/SnapshotModal.tsx | 69 ++++++++ .../src/pages/Camera.tsx | 6 + src/OpenIPC.Viewer.Web.Client/src/strings.ts | 10 ++ src/OpenIPC.Viewer.Web.Client/src/styles.css | 14 +- src/OpenIPC.Viewer.Web/Api/LiveFfmpeg.cs | 3 + src/OpenIPC.Viewer.Web/Api/SnapshotApi.cs | 156 ++++++++++++++++++ src/OpenIPC.Viewer.Web/WebServer.cs | 1 + 9 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 src/OpenIPC.Viewer.Web.Client/src/components/SnapshotModal.tsx create mode 100644 src/OpenIPC.Viewer.Web/Api/SnapshotApi.cs diff --git a/src/OpenIPC.Viewer.Web.Client/src/api.ts b/src/OpenIPC.Viewer.Web.Client/src/api.ts index 4fa6127..0d512e7 100644 --- a/src/OpenIPC.Viewer.Web.Client/src/api.ts +++ b/src/OpenIPC.Viewer.Web.Client/src/api.ts @@ -174,6 +174,11 @@ export const api = { req('POST', '/api/v1/discovery/probe', body), addDiscovered: (body: DiscoveryAdd) => req('POST', '/api/v1/discovery/add', body), + // A fresh still, straight from the camera (Majestic /image.jpg when available, + // otherwise one ffmpeg pull). Used as an src and as a download target, + // so it's a URL rather than a fetch wrapper. + snapshotUrl: (id: string) => `/api/v1/cameras/${id}/snapshot`, + // PTZ. Movement is stateless on the server: each move carries a self-stop // timeout, so the caller must repeat it while a direction is held (see PtzPad) // and send stop on release. A camera that never gets the stop halts on its own. diff --git a/src/OpenIPC.Viewer.Web.Client/src/components/LiveTile.tsx b/src/OpenIPC.Viewer.Web.Client/src/components/LiveTile.tsx index cdd2ff7..7aa3322 100644 --- a/src/OpenIPC.Viewer.Web.Client/src/components/LiveTile.tsx +++ b/src/OpenIPC.Viewer.Web.Client/src/components/LiveTile.tsx @@ -1,6 +1,7 @@ import { memo, useRef, useState } from 'react' import type { CameraDto } from '../api' import { useLiveTile } from '../hooks/useLiveTile' +import { SnapshotModal } from './SnapshotModal' // Collapse the raw useLiveTile status string into a small set of visual states: // a dot colour + a terse label, plus whether to show the "still connecting" @@ -24,6 +25,7 @@ export function statusKind(status: string): { kind: Kind; label: string } { // (route change, page flip) leaves the stream running for a grace period. export const LiveTile = memo(function LiveTile({ camera }: { camera: CameraDto }) { const [slot, setSlot] = useState(null) + const [snapshot, setSnapshot] = useState(false) const cellRef = useRef(null) const status = useLiveTile(slot, camera.id) const { kind, label } = statusKind(status) @@ -48,9 +50,15 @@ export const LiveTile = memo(function LiveTile({ camera }: { camera: CameraDto } {label} {camera.name} + + {snapshot && ( + setSnapshot(false)} /> + )} ) }) diff --git a/src/OpenIPC.Viewer.Web.Client/src/components/SnapshotModal.tsx b/src/OpenIPC.Viewer.Web.Client/src/components/SnapshotModal.tsx new file mode 100644 index 0000000..6966906 --- /dev/null +++ b/src/OpenIPC.Viewer.Web.Client/src/components/SnapshotModal.tsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from 'react' +import { api } from '../api' +import { useI18n } from '../i18n' + +// A still, taken on demand and shown full size with a download link. +// +// The image is fetched as a blob rather than pointed at with : the +// endpoint answers 502 when the camera doesn't respond, and an would just +// show a broken icon with no way to say why. A blob URL also makes the download +// keep the name we choose instead of "snapshot". +export function SnapshotModal({ cameraId, cameraName, onClose }: { + cameraId: string + cameraName: string + onClose: () => void +}) { + const { t } = useI18n() + const [url, setUrl] = useState(null) + const [error, setError] = useState(false) + + useEffect(() => { + let revoked: string | null = null + let cancelled = false + void (async () => { + try { + const res = await fetch(api.snapshotUrl(cameraId), { credentials: 'same-origin' }) + if (!res.ok) throw new Error(String(res.status)) + const blob = await res.blob() + if (cancelled) return + revoked = URL.createObjectURL(blob) + setUrl(revoked) + } catch { + if (!cancelled) setError(true) + } + })() + return () => { + cancelled = true + if (revoked) URL.revokeObjectURL(revoked) + } + }, [cameraId]) + + // Local time in the filename, since that's the clock the viewer thinks in. + const stamp = new Date() + .toLocaleString('sv-SE') + .replace(/[: ]/g, '-') + const fileName = `${cameraName.replace(/[^\w.-]+/g, '_')}-${stamp}.jpg` + + return ( +
+
e.stopPropagation()}> +

{cameraName}

+ {error ? ( +

{t('Snapshot.Failed')}

+ ) : url ? ( + {cameraName} + ) : ( +

{t('Snapshot.Taking')}

+ )} +
+ + {url && ( + + {t('Snapshot.Download')} + + )} +
+
+
+ ) +} diff --git a/src/OpenIPC.Viewer.Web.Client/src/pages/Camera.tsx b/src/OpenIPC.Viewer.Web.Client/src/pages/Camera.tsx index 97b639a..111103c 100644 --- a/src/OpenIPC.Viewer.Web.Client/src/pages/Camera.tsx +++ b/src/OpenIPC.Viewer.Web.Client/src/pages/Camera.tsx @@ -7,6 +7,7 @@ import { LiveTile } from '../components/LiveTile' import { CameraEditor } from '../components/CameraEditor' import { PtzPad } from '../components/PtzPad' import { ConfirmModal } from '../components/Modals' +import { SnapshotModal } from '../components/SnapshotModal' // Dedicated single-camera view: one large live tile plus the camera's details // and actions. This is the natural shape on a phone (you watch one camera at a @@ -20,6 +21,7 @@ export function Camera() { const [groups, setGroups] = useState([]) const [editing, setEditing] = useState(false) const [deleting, setDeleting] = useState(false) + const [snapshot, setSnapshot] = useState(false) const load = async () => { const [cams, grps] = await Promise.all([api.cameras(), api.groups()]) @@ -54,6 +56,7 @@ export function Camera() { {t('Live.Back')}

{camera.name}

+ {can('Manage') && ( <> @@ -119,6 +122,9 @@ export function Camera() { }} /> )} + {snapshot && ( + setSnapshot(false)} /> + )} {deleting && ( Resolve(); + private static string Resolve() { string? rid = null; diff --git a/src/OpenIPC.Viewer.Web/Api/SnapshotApi.cs b/src/OpenIPC.Viewer.Web/Api/SnapshotApi.cs new file mode 100644 index 0000000..63f0985 --- /dev/null +++ b/src/OpenIPC.Viewer.Web/Api/SnapshotApi.cs @@ -0,0 +1,156 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Majestic; +using OpenIPC.Viewer.Core.Services; +using OpenIPC.Viewer.Web.Auth; +using static OpenIPC.Viewer.Web.Api.ApiHelpers; + +namespace OpenIPC.Viewer.Web.Api; + +// A still from a camera, on demand. +// +// Two sources, in the desktop's order of preference: Majestic's own +// /image.jpg (the camera encodes it, so it costs us nothing and comes at +// sensor resolution), falling back to one short ffmpeg pull off the RTSP +// stream for cameras that aren't Majestic or have the HTTP server off. +// +// Nothing is stored: this hands the browser the bytes, which is what "take a +// snapshot" means from a web client. The shared snapshot library the desktop +// keeps (indexed, thumbnailed, browsable) is a separate slice — it needs the +// image/thumbnail stack the server head doesn't compose yet. +public static class SnapshotApi +{ + private static readonly TimeSpan MajesticTimeout = TimeSpan.FromSeconds(6); + private static readonly TimeSpan FfmpegTimeout = TimeSpan.FromSeconds(15); + + public static void MapSnapshotEndpoints(this WebApplication app) + { + app.MapGet("/api/v1/cameras/{id}/snapshot", async (string id, HttpContext ctx, CancellationToken ct) => + { + // Seeing a still is seeing the camera — same permission as live. + if (ctx.Deny(WebPermission.ViewLive) is { } denied) + return denied; + if (ctx.DenyCamera(id) is { } hidden) + return hidden; + + var dir = ctx.RequestServices.GetService(); + if (dir is null) + return BackendUnavailable(); + if (!Guid.TryParse(id, out var guid)) + return ValidationError("invalid camera id"); + + var camera = await dir.GetAsync(new CameraId(guid), ct); + if (camera is null) + return NotFound(); + + var credentials = await dir.GetCredentialsAsync(camera.Id, ct); + var logger = ctx.RequestServices.GetRequiredService().CreateLogger("OpenIPC.Web.Snapshot"); + + var jpeg = await TryMajesticAsync(ctx, camera, credentials, logger, ct) + ?? await TryFfmpegAsync(camera, credentials, logger, ct); + + if (jpeg is null || jpeg.Length == 0) + return Results.Json(new { error = "snapshot_failed" }, statusCode: StatusCodes.Status502BadGateway); + + // No caching: a still is a point in time, and the next request means + // the viewer wants a fresh one. + ctx.Response.Headers.CacheControl = "no-store"; + return Results.File(jpeg, "image/jpeg"); + }); + } + + private static async Task TryMajesticAsync( + HttpContext ctx, Camera camera, CameraCredentials? credentials, ILogger logger, CancellationToken ct) + { + var majestic = ctx.RequestServices.GetService(); + if (majestic is null || !camera.IsMajestic) + return null; + + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(MajesticTimeout); + var endpoint = new MajesticEndpoint(camera.Host, camera.HttpPort, credentials); + return await majestic.SnapshotJpegAsync(endpoint, new MajesticSnapshotOptions(), timeout.Token); + } + catch (Exception ex) + { + logger.LogInformation(ex, "Majestic snapshot of {Host} failed — falling back to ffmpeg", camera.Host); + return null; + } + } + + // One frame off the mainstream. Writes to stdout so nothing lands on disk, + // and dies with the request if the camera stops answering mid-pull. + private static async Task TryFfmpegAsync( + Camera camera, CameraCredentials? credentials, ILogger logger, CancellationToken ct) + { + var url = BuildRtspUrl(camera.RtspMainUri, credentials); + var psi = new ProcessStartInfo(LiveFfmpeg.ResolveExecutable()) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var arg in new[] + { + "-rtsp_transport", "tcp", "-i", url, + "-frames:v", "1", "-q:v", "3", + "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1", + }) + { + psi.ArgumentList.Add(arg); + } + + Process? proc = null; + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(FfmpegTimeout); + proc = Process.Start(psi); + if (proc is null) + return null; + + _ = LiveFfmpeg.DrainStderrAsync(proc, logger); + using var buffer = new MemoryStream(); + await proc.StandardOutput.BaseStream.CopyToAsync(buffer, timeout.Token); + await proc.WaitForExitAsync(timeout.Token); + return buffer.Length > 0 ? buffer.ToArray() : null; + } + catch (Exception ex) + { + logger.LogWarning(ex, "ffmpeg snapshot of {Host} failed", camera.Host); + return null; + } + finally + { + try + { + if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); + } + catch { /* already gone */ } + proc?.Dispose(); + } + } + + // Credentials live in the secrets store, not in the stored URI. + private static string BuildRtspUrl(Uri baseUri, CameraCredentials? credentials) + { + if (credentials is null || string.IsNullOrEmpty(credentials.Username)) + return baseUri.ToString(); + return new UriBuilder(baseUri) + { + UserName = credentials.Username, + Password = credentials.Password, + }.Uri.ToString(); + } +} diff --git a/src/OpenIPC.Viewer.Web/WebServer.cs b/src/OpenIPC.Viewer.Web/WebServer.cs index f3def0d..54d715d 100644 --- a/src/OpenIPC.Viewer.Web/WebServer.cs +++ b/src/OpenIPC.Viewer.Web/WebServer.cs @@ -183,6 +183,7 @@ private static void MapEndpoints(WebApplication app, WebServerOptions options) app.MapLayoutEndpoints(); app.MapLiveEndpoints(); app.MapPtzEndpoints(); + app.MapSnapshotEndpoints(); app.MapDiscoveryEndpoints(); app.MapSystemEndpoints(); app.MapUserEndpoints(); From cabe540d056c357b2f091b52b192332271f407e3 Mon Sep 17 00:00:00 2001 From: keyldev Date: Wed, 22 Jul 2026 01:57:17 +0300 Subject: [PATCH 02/13] web: browse and play the recorded archive - GET /api/v1/recordings lists what the desktop head recorded, scoped to the caller's cameras; /stream serves the file with range support so the browser's own