feat(snapshot): re-encode captures + disk-pressure retention
Camera snapshots were stored RAW — the camera's full-res JPEG straight into the BLOB, no resize/recompress. Measured on the dev DB: 300 snapshots = 81.7 MB = ~72% of the 114 MB SQLite file (the big ones 2688×1520 / ~600 KB, Hikvision main stream). They dominated the appliance's single backed-up DB file. Re-encode on capture (snapshot.ts): - Downscale each frame to SNAPSHOT_MAX_EDGE (1280px long edge) + recompress at SNAPSHOT_JPEG_QUALITY (80) via sharp (libvips, Apache-2.0) before storage — ~6-10× smaller (verified 2688×1520 → 1280×724, ~8×), plate still readable, clean image/jpeg (drops the camera's charset cruft). STORAGE-ONLY: recognition keeps the ORIGINAL full-res bytes (downscaling hurts OCR). Fail-soft — a re-encode error stores the original, never drops the snapshot or blocks the (already-open) path. sharp lives in apps/server (owns the capture path), where bcrypt already establishes the native-dep pattern. Disk-pressure retention (snapshot-retention.ts) — a SAFETY VALVE, not the daily mechanism (the re-encode does that). Daily check reads the DB filesystem used% (statfs on db.$client.name); no-op unless ≥ SNAPSHOT_DISK_HIGH_PCT (70). Over the mark: delete the OLDEST until an estimated SNAPSHOT_DISK_FREE_TARGET_PCT (10%) of disk is freed — never below SNAPSHOT_MIN_KEEP (500) — then VACUUM once to return space to the OS. A DELETE only frees SQLite pages (disk doesn't drop until VACUUM), so the loop is driven by estimated freed bytes (SUM(length(bytes))), not a live disk re-read; the prune owns the DB-locking VACUUM, run daily off-peak. diskUsage is injectable for tests. None of this touches the signed ledger — snapshots are unsigned/advisory, referenced only by id. Tests: encodeForStorage (downscale / clean-type / no-enlarge / fail-soft) + pruneSnapshots (no-op below mark / delete-oldest-to-target + VACUUM / MIN_KEEP floor / skip-VACUUM-when-empty). All four snapshot env knobs documented in the komodo env reference. Full workspace build/lint/test green; the prune smoke-verified on a scratch DB copy (file shrank after VACUUM). Existing ~81.7 MB of raw snapshots are unchanged (a one-off re-encode backfill is a separate optional follow-up). Updated entry-exit-points + technology-stack wiki. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -23,7 +23,8 @@
|
||||
"@parking/shared": "workspace:*",
|
||||
"bcrypt": "6.0.0",
|
||||
"fastify": "5.8.5",
|
||||
"fastify-plugin": "6.0.0"
|
||||
"fastify-plugin": "6.0.0",
|
||||
"sharp": "^0.35.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "6.0.0",
|
||||
|
||||
@@ -20,6 +20,7 @@ import { PrinterMonitor } from "./printer-monitor.js";
|
||||
import { DeviceMonitor } from "./device-monitor.js";
|
||||
import { buildSigner, buildVerifier } from "./signer.js";
|
||||
import { LogService, pinoDbStream } from "./log-service.js";
|
||||
import { pruneSnapshots } from "./snapshot-retention.js";
|
||||
import { logRoutes } from "./routes/logs.js";
|
||||
import { VisionClient } from "./vision-client.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
@@ -283,6 +284,23 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
logService.prune(); // once at startup
|
||||
app.addHook("onClose", async () => clearInterval(pruneTimer));
|
||||
|
||||
// Snapshot retention prune — DISK-PRESSURE safety valve: only when the DB's filesystem
|
||||
// crosses the high-water mark do we delete the oldest snapshots + VACUUM. A no-op the rest
|
||||
// of the time. Daily, unref'd, plus once at startup. See snapshot-retention.ts.
|
||||
const runSnapPrune = async () => {
|
||||
const res = await pruneSnapshots(db, {}, app.log);
|
||||
if (res.deletedRows > 0) {
|
||||
app.log.info(
|
||||
`pruned ${res.deletedRows} snapshots, freed ~${(res.freedBytesEst / 1048576).toFixed(0)} MB ` +
|
||||
`(disk was ${res.usedPctBefore.toFixed(0)}% used${res.vacuumed ? ", vacuumed" : ""})`,
|
||||
);
|
||||
}
|
||||
};
|
||||
const snapPruneTimer = setInterval(() => void runSnapPrune(), 24 * 60 * 60 * 1000);
|
||||
snapPruneTimer.unref();
|
||||
void runSnapPrune(); // once at startup
|
||||
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
||||
|
||||
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
|
||||
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
|
||||
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { snapshots, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { pruneSnapshots, type DiskUsage, type SnapshotRetention } from "./snapshot-retention.js";
|
||||
|
||||
// Snapshot retention: DISK-PRESSURE prune. No-op unless the DB's filesystem is over the
|
||||
// high-water mark; then delete the OLDEST until ~freeTargetPct of disk is freed (estimated from
|
||||
// the deleted BLOB sizes), honoring a MIN_KEEP floor, then VACUUM once. Disk usage is injected
|
||||
// so the test controls the trigger without touching the real filesystem.
|
||||
|
||||
let db: Db;
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
});
|
||||
|
||||
/** Insert `n` snapshots, oldest first (s-0 is the oldest), each `bytes` long. */
|
||||
function seed(n: number, bytes = 1000): void {
|
||||
const t0 = Date.now() - n * 1000;
|
||||
for (let i = 0; i < n; i++) {
|
||||
db.insert(snapshots)
|
||||
.values({
|
||||
id: `s-${i}`,
|
||||
direction: "entry",
|
||||
deviceId: "cam",
|
||||
identity: `s-${i}`,
|
||||
contentType: "image/jpeg",
|
||||
bytes: Buffer.alloc(bytes, 1),
|
||||
capturedAt: new Date(t0 + i * 1000).toISOString(), // s-0 oldest … s-(n-1) newest
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
function count(): number {
|
||||
return db.select().from(snapshots).all().length;
|
||||
}
|
||||
function ids(): string[] {
|
||||
return db.select().from(snapshots).all().map((r) => r.id).sort();
|
||||
}
|
||||
|
||||
/** A fake disk at a given used% on a 1 GB volume. */
|
||||
const disk = (usedPct: number, totalBytes = 1_000_000_000): (() => Promise<DiskUsage>) =>
|
||||
() => Promise.resolve({ usedPct, totalBytes });
|
||||
|
||||
const ret = (o: Partial<SnapshotRetention>): SnapshotRetention => ({
|
||||
highPct: 70,
|
||||
freeTargetPct: 10,
|
||||
minKeep: 2,
|
||||
batch: 5,
|
||||
...o,
|
||||
});
|
||||
|
||||
describe("pruneSnapshots (disk-pressure)", () => {
|
||||
it("no-op when disk is below the high-water mark", async () => {
|
||||
seed(10);
|
||||
const res = await pruneSnapshots(db, { retention: ret({}), diskUsage: disk(50) });
|
||||
expect(res.deletedRows).toBe(0);
|
||||
expect(res.vacuumed).toBe(false);
|
||||
expect(count()).toBe(10);
|
||||
});
|
||||
|
||||
it("over the mark: deletes the OLDEST until ~freeTargetPct is freed, then VACUUMs", async () => {
|
||||
// 1 GB disk, target 10% = 100 MB. Each snapshot 20 MB → ~5 deletions reach the target.
|
||||
seed(20, 20 * 1048576);
|
||||
const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec");
|
||||
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, batch: 100 }), diskUsage: disk(80) });
|
||||
expect(res.deletedRows).toBeGreaterThanOrEqual(5);
|
||||
expect(res.freedBytesEst).toBeGreaterThanOrEqual(0.1 * 1_000_000_000);
|
||||
expect(res.vacuumed).toBe(true);
|
||||
expect(vacuumSpy).toHaveBeenCalledWith("VACUUM");
|
||||
// The survivors are the NEWEST (oldest went first).
|
||||
const survivors = ids();
|
||||
expect(survivors).toContain(`s-19`); // newest kept
|
||||
expect(survivors).not.toContain(`s-0`); // oldest pruned
|
||||
vacuumSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("honors the MIN_KEEP floor even when still over target", async () => {
|
||||
// Target 10% of 1 GB = 100 MB, but only 3 tiny snapshots exist and minKeep=2 → at most 1 deleted.
|
||||
seed(3, 1000);
|
||||
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, freeTargetPct: 90 }), diskUsage: disk(95) });
|
||||
expect(res.deletedRows).toBe(1); // 3 − minKeep(2)
|
||||
expect(count()).toBe(2);
|
||||
expect(res.floorHitWhileOver).toBe(true); // couldn't reach target without crossing the floor
|
||||
});
|
||||
|
||||
it("skips VACUUM when nothing was deleted", async () => {
|
||||
seed(2); // == minKeep, so nothing to delete even over the mark
|
||||
const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec");
|
||||
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2 }), diskUsage: disk(99) });
|
||||
expect(res.deletedRows).toBe(0);
|
||||
expect(res.vacuumed).toBe(false);
|
||||
expect(vacuumSpy).not.toHaveBeenCalled();
|
||||
vacuumSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { statfs } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { asc, snapshots, sql, type Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
|
||||
// Snapshot retention — DISK-PRESSURE model. Camera snapshots are unsigned, advisory, prunable
|
||||
// BLOBs (see snapshot.ts); they're referenced by the signed ledger only by id, so pruning an
|
||||
// old image never affects the chain. They no longer dominate the DB day-to-day (captures are
|
||||
// re-encoded small at SNAPSHOT_MAX_EDGE/JPEG_QUALITY), so this is a SAFETY VALVE: only when the
|
||||
// filesystem holding the DB crosses a high-water mark do we delete the OLDEST snapshots and
|
||||
// VACUUM to return disk to the OS.
|
||||
//
|
||||
// Why estimated-bytes, not live disk%: a DELETE only frees SQLite *pages* — the file (and thus
|
||||
// OS disk usage) doesn't shrink until VACUUM. So the prune loop can't watch usedPct fall in real
|
||||
// time. Instead it sums LENGTH(bytes) of the rows it deletes and stops when that estimate reaches
|
||||
// the free-target, then VACUUMs ONCE at the end to realize the space. A MIN_KEEP floor always
|
||||
// wins — we never delete evidence below it, even under pressure (if the disk is full of something
|
||||
// else, that's not ours to fix).
|
||||
|
||||
export interface SnapshotRetention {
|
||||
/** Prune when the DB's filesystem is at least this % used. */
|
||||
readonly highPct: number;
|
||||
/** Try to free roughly this % of the disk per run (the delete target). */
|
||||
readonly freeTargetPct: number;
|
||||
/** Never prune below this many snapshots (the floor). */
|
||||
readonly minKeep: number;
|
||||
/** Delete oldest in batches of this size (re-checks between batches). */
|
||||
readonly batch: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SNAPSHOT_RETENTION: SnapshotRetention = {
|
||||
highPct: Number(process.env.SNAPSHOT_DISK_HIGH_PCT ?? 70),
|
||||
freeTargetPct: Number(process.env.SNAPSHOT_DISK_FREE_TARGET_PCT ?? 10),
|
||||
minKeep: Number(process.env.SNAPSHOT_MIN_KEEP ?? 500),
|
||||
batch: Number(process.env.SNAPSHOT_PRUNE_BATCH ?? 200),
|
||||
};
|
||||
|
||||
/** Disk usage of the filesystem holding the DB. Injectable so tests don't touch the real FS. */
|
||||
export interface DiskUsage {
|
||||
readonly usedPct: number;
|
||||
readonly totalBytes: number;
|
||||
}
|
||||
|
||||
export interface PruneOptions {
|
||||
readonly retention?: SnapshotRetention;
|
||||
/** Override how disk usage is read (tests inject a fake; default = statfs the DB's FS). */
|
||||
readonly diskUsage?: () => Promise<DiskUsage>;
|
||||
}
|
||||
|
||||
export interface PruneResult {
|
||||
readonly deletedRows: number;
|
||||
readonly freedBytesEst: number;
|
||||
readonly vacuumed: boolean;
|
||||
readonly usedPctBefore: number;
|
||||
/** True if we hit the MIN_KEEP floor while the disk was still over the high-water mark. */
|
||||
readonly floorHitWhileOver: boolean;
|
||||
}
|
||||
|
||||
/** Read the used% + total bytes of the filesystem holding the DB file. */
|
||||
async function diskUsageForDb(db: Db): Promise<DiskUsage> {
|
||||
const file = (db.$client as { name?: string }).name ?? process.env.DATABASE_URL ?? "./parking.sqlite";
|
||||
const st = await statfs(dirname(resolve(file)));
|
||||
const total = st.blocks * st.bsize;
|
||||
const avail = st.bavail * st.bsize;
|
||||
const usedPct = total > 0 ? (1 - avail / total) * 100 : 0;
|
||||
return { usedPct, totalBytes: total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune snapshots under DISK PRESSURE. No-op unless the DB's filesystem is ≥ highPct used. When
|
||||
* over, deletes the OLDEST snapshots until an estimated freeTargetPct of the disk is freed (or the
|
||||
* minKeep floor is hit, or no rows remain), then VACUUMs once. Best-effort; safe on a timer.
|
||||
*/
|
||||
export async function pruneSnapshots(
|
||||
db: Db,
|
||||
opts: PruneOptions = {},
|
||||
logger?: FastifyBaseLogger,
|
||||
): Promise<PruneResult> {
|
||||
const r = opts.retention ?? DEFAULT_SNAPSHOT_RETENTION;
|
||||
const readDisk = opts.diskUsage ?? (() => diskUsageForDb(db));
|
||||
|
||||
let usedPctBefore = 0;
|
||||
try {
|
||||
const disk = await readDisk();
|
||||
usedPctBefore = disk.usedPct;
|
||||
|
||||
// The overwhelmingly common case: plenty of headroom → do nothing.
|
||||
if (disk.usedPct < r.highPct) {
|
||||
return { deletedRows: 0, freedBytesEst: 0, vacuumed: false, usedPctBefore, floorHitWhileOver: false };
|
||||
}
|
||||
|
||||
// Target bytes to free this run (≈ freeTargetPct of the whole disk).
|
||||
const targetBytes = (r.freeTargetPct / 100) * disk.totalBytes;
|
||||
|
||||
let freedBytesEst = 0;
|
||||
let deletedRows = 0;
|
||||
let floorHitWhileOver = false;
|
||||
|
||||
// Delete the oldest in batches, summing their BLOB sizes, until we've freed the target — or
|
||||
// we'd cross the MIN_KEEP floor — or there are no more rows.
|
||||
for (;;) {
|
||||
const count = db.select({ c: sql<number>`count(*)` }).from(snapshots).get()?.c ?? 0;
|
||||
if (count <= r.minKeep) {
|
||||
floorHitWhileOver = true; // still over the high-water mark but can't delete below the floor
|
||||
break;
|
||||
}
|
||||
if (freedBytesEst >= targetBytes) break;
|
||||
|
||||
const room = count - r.minKeep; // how many we may still delete before the floor
|
||||
const take = Math.min(r.batch, room);
|
||||
const oldest = db
|
||||
.select({ id: snapshots.id, len: sql<number>`length(${snapshots.bytes})` })
|
||||
.from(snapshots)
|
||||
.orderBy(asc(snapshots.capturedAt))
|
||||
.limit(take)
|
||||
.all();
|
||||
if (oldest.length === 0) break;
|
||||
|
||||
const ids = oldest.map((o) => o.id);
|
||||
db.delete(snapshots).where(sql`${snapshots.id} in (${sql.join(ids, sql`, `)})`).run();
|
||||
deletedRows += oldest.length;
|
||||
freedBytesEst += oldest.reduce((s, o) => s + (o.len ?? 0), 0);
|
||||
}
|
||||
|
||||
// Realize the freed space: VACUUM returns pages to the OS (the file shrinks). Only if we
|
||||
// actually deleted something. Non-fatal on failure — pages are still freed for reuse.
|
||||
let vacuumed = false;
|
||||
if (deletedRows > 0) {
|
||||
try {
|
||||
(db.$client as { exec: (sql: string) => void }).exec("VACUUM");
|
||||
vacuumed = true;
|
||||
} catch (err) {
|
||||
logger?.warn(`snapshot prune: VACUUM failed (pages freed for reuse): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (floorHitWhileOver) {
|
||||
logger?.warn(
|
||||
`snapshot prune: disk ${usedPctBefore.toFixed(0)}% used but hit MIN_KEEP floor (${r.minKeep}) ` +
|
||||
`after deleting ${deletedRows} — disk pressure is not from snapshots`,
|
||||
);
|
||||
}
|
||||
return { deletedRows, freedBytesEst, vacuumed, usedPctBefore, floorHitWhileOver };
|
||||
} catch (err) {
|
||||
logger?.warn(`snapshot prune failed: ${(err as Error).message}`);
|
||||
return { deletedRows: 0, freedBytesEst: 0, vacuumed: false, usedPctBefore, floorHitWhileOver: false };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import sharp from "sharp";
|
||||
import type { CameraDevice, Snapshot } from "@parking/devices";
|
||||
import { captureSnapshotShared } from "./snapshot.js";
|
||||
import { captureSnapshotShared, encodeForStorage } from "./snapshot.js";
|
||||
import { silentLogger } from "./test-helpers.js";
|
||||
|
||||
// captureSnapshotShared: one HTTP pull per camera per vehicle. A Hikvision unit serves
|
||||
// snapshots SINGLE-THREADED (a 2nd concurrent GET → HTTP 503). On an entry the ANPR
|
||||
@@ -91,3 +93,49 @@ describe("captureSnapshotShared", () => {
|
||||
expect(s1.bytes.equals(s2.bytes)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// encodeForStorage: downscale + re-compress a captured frame for STORAGE (smaller, plate
|
||||
// still readable). Recognition uses the original; this never runs on the OCR path. Fail-soft.
|
||||
describe("encodeForStorage", () => {
|
||||
/** A big synthetic JPEG (2688×1520, the Hikvision main-stream size) to downscale. */
|
||||
async function bigJpeg(): Promise<Buffer> {
|
||||
return sharp({
|
||||
create: { width: 2688, height: 1520, channels: 3, background: { r: 120, g: 130, b: 140 } },
|
||||
})
|
||||
.jpeg({ quality: 95 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
it("downscales the long edge to ≤1280 and emits clean image/jpeg", async () => {
|
||||
const bytes = await bigJpeg();
|
||||
const shot: Snapshot = { bytes, contentType: 'image/jpeg; charset="UTF-8"', capturedAt: new Date().toISOString() };
|
||||
const out = await encodeForStorage(shot, silentLogger());
|
||||
expect(out.contentType).toBe("image/jpeg"); // charset cruft stripped
|
||||
const meta = await sharp(out.bytes).metadata();
|
||||
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1280);
|
||||
expect(out.bytes.length).toBeLessThan(bytes.length); // smaller than the original
|
||||
});
|
||||
|
||||
it("never enlarges an already-small image", async () => {
|
||||
const small = await sharp({ create: { width: 640, height: 360, channels: 3, background: { r: 0, g: 0, b: 0 } } })
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
const out = await encodeForStorage(
|
||||
{ bytes: small, contentType: "image/jpeg", capturedAt: new Date().toISOString() },
|
||||
silentLogger(),
|
||||
);
|
||||
const meta = await sharp(out.bytes).metadata();
|
||||
expect(meta.width).toBe(640); // withoutEnlargement
|
||||
expect(meta.height).toBe(360);
|
||||
});
|
||||
|
||||
it("fails soft: a non-image body is stored unchanged with a cleaned type", async () => {
|
||||
const garbage = Buffer.from("this is not an image");
|
||||
const out = await encodeForStorage(
|
||||
{ bytes: garbage, contentType: 'text/plain; charset="UTF-8"', capturedAt: new Date().toISOString() },
|
||||
silentLogger(),
|
||||
);
|
||||
expect(out.bytes.equals(garbage)).toBe(true); // original bytes, never dropped
|
||||
expect(out.contentType).toBe("text/plain"); // charset stripped even on the fallback
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
||||
import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
@@ -27,6 +28,43 @@ import type { VisionClient } from "./vision-client.js";
|
||||
// fire-and-forget: it never blocks the open and never changes the entry/exit decision —
|
||||
// it's a record ("session X entered on plate AA558EE"). No polling; recognition only
|
||||
// happens on a real entry/exit. See wiki/entities/opencv-anpr-service.md.
|
||||
//
|
||||
// STORAGE RE-ENCODE (2026-06-28). Cameras serve full-res JPEGs (a Hikvision main stream is
|
||||
// 2688×1520 / ~600 KB); stored raw, snapshots dominated the appliance DB (~72%). Each frame
|
||||
// is now downscaled (long edge ≤ SNAPSHOT_MAX_EDGE) + re-compressed (q SNAPSHOT_JPEG_QUALITY)
|
||||
// BEFORE storage — ~6–10× smaller, plate still clearly readable. RECOGNITION runs on the
|
||||
// ORIGINAL full-res bytes (downscaling hurts OCR); the re-encode is storage-only. Fail-soft:
|
||||
// a re-encode error stores the original, never drops the snapshot or blocks the open.
|
||||
|
||||
/** Long-edge cap (px) + JPEG quality for the STORED snapshot. Env-overridable per appliance. */
|
||||
const SNAP_MAX_EDGE = Number(process.env.SNAPSHOT_MAX_EDGE ?? 1280);
|
||||
const SNAP_QUALITY = Number(process.env.SNAPSHOT_JPEG_QUALITY ?? 80);
|
||||
|
||||
/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). */
|
||||
function cleanType(ct: string): string {
|
||||
const base = ct.split(";")[0]?.trim();
|
||||
return base || "image/jpeg";
|
||||
}
|
||||
|
||||
/** Downscale + re-encode a captured frame for STORAGE (evidence, not OCR). Caps the long edge
|
||||
* and re-compresses to JPEG. Fail-soft: any error (e.g. a non-image body) returns the original
|
||||
* bytes with a cleaned content type, so a snapshot is never lost. */
|
||||
export async function encodeForStorage(
|
||||
shot: Snapshot,
|
||||
logger: FastifyBaseLogger,
|
||||
): Promise<{ bytes: Buffer; contentType: string }> {
|
||||
try {
|
||||
const out = await sharp(shot.bytes, { failOn: "none" })
|
||||
.rotate() // honor EXIF orientation before we drop the metadata
|
||||
.resize({ width: SNAP_MAX_EDGE, height: SNAP_MAX_EDGE, fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: SNAP_QUALITY, mozjpeg: true })
|
||||
.toBuffer();
|
||||
return { bytes: out, contentType: "image/jpeg" };
|
||||
} catch (err) {
|
||||
logger.warn(`snapshot re-encode failed, storing original: ${(err as Error).message}`);
|
||||
return { bytes: shot.bytes, contentType: cleanType(shot.contentType) };
|
||||
}
|
||||
}
|
||||
|
||||
interface SnapshotJob {
|
||||
readonly db: Db;
|
||||
@@ -67,14 +105,17 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
||||
// same vehicle, reuse it instead of a 2nd concurrent GET (which 503s).
|
||||
const shot = await captureSnapshotShared(row.id, camera, { direction });
|
||||
const id: string = randomUUID();
|
||||
// Re-encode for STORAGE only (downscale + recompress). Recognition below still
|
||||
// uses the original full-res `shot`.
|
||||
const stored = await encodeForStorage(shot, logger);
|
||||
db.insert(snapshots)
|
||||
.values({
|
||||
id,
|
||||
direction,
|
||||
deviceId: row.id,
|
||||
identity,
|
||||
contentType: shot.contentType,
|
||||
bytes: shot.bytes,
|
||||
contentType: stored.contentType,
|
||||
bytes: stored.bytes,
|
||||
capturedAt: shot.capturedAt,
|
||||
})
|
||||
.run();
|
||||
|
||||
Reference in New Issue
Block a user