Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
f19cbe0
feat: begin rewrite of web client
brandonbothell Sep 3, 2026
f6304fb
feat: display streams in resizable grid
brandonbothell Sep 3, 2026
a2a6ba6
feat(web2): signed URL expiration handling & live edge seeking
brandonbothell Sep 3, 2026
60c8a1f
fix(web2): video grid aspect ratio/grid controls & flashing
brandonbothell Sep 4, 2026
1cd22e5
fix: build web2 in build script, fix build script(s)/tsconfigs for es…
brandonbothell Sep 4, 2026
810a0e4
fix: remove extra .d.ts file
brandonbothell Sep 4, 2026
d4e9af7
feat(web2): start work on recordings list
brandonbothell Sep 4, 2026
e18d568
feat(web2): implement recordings page with stream selection and pagin…
brandonbothell Sep 4, 2026
4bdb722
fix: don't show update popup on first launch
brandonbothell Sep 5, 2026
d456d44
fix: streams not playing on chrome due to video codec pickiness
brandonbothell Sep 5, 2026
e74c89b
feat(web2): start the recordings grid, update recordings page size to…
brandonbothell Sep 6, 2026
ba58622
fix: only create the correct number of empty pages between the last p…
brandonbothell Sep 8, 2026
689bed6
refactor: reorganize components into subfolders
brandonbothell Sep 8, 2026
04ec4dd
feat: recording list timestamps/formatting
brandonbothell Sep 8, 2026
b712bf7
feat: add layout management
brandonbothell Sep 8, 2026
5898fa8
fix: remove full-path file imports from web2
brandonbothell Sep 8, 2026
de33dc9
feat: animate navbar chevron
brandonbothell Sep 8, 2026
a73b3b8
fix: page jumping when loading recordings' images
brandonbothell Sep 8, 2026
9db9b3d
feat: recordings viewing via lightbox
brandonbothell Sep 8, 2026
3bd7769
feat: implement secure http storage for refresh tokens in dev
brandonbothell Sep 8, 2026
50a6d70
feat: add inline fullscreen video support and enhance video controls
brandonbothell Sep 8, 2026
6910b57
fix: adjust inline fullscreen behavior for smaller screens
brandonbothell Sep 8, 2026
312f0b7
feat: auto collapse the lightbox thumbnails on open
brandonbothell Sep 8, 2026
f52f141
feat: add download functionality to lightbox and adjust video y-trans…
brandonbothell Sep 8, 2026
b2323e0
fix: remove unnecessary voids
brandonbothell Sep 9, 2026
8547902
fix: correct aspect-ratio syntax in thumbnailFrame and enhance splitt…
brandonbothell Sep 9, 2026
b4599d9
feat: enhance video component's mobile fullscreen support and adjust …
brandonbothell Sep 9, 2026
fe7b026
feat: update scripts to web2 and ignore dist when linting
brandonbothell Sep 9, 2026
4280bb3
fix: rate limit static resources
brandonbothell Sep 9, 2026
69c1053
feat: add delete recording functionality and enhance recordings grid …
brandonbothell Sep 9, 2026
ddcdfff
fix: improve recording retrieval logic and enhance logging for lightb…
brandonbothell Sep 9, 2026
27b3738
refactor: remove commented code and refactor onRecordingDeleted
brandonbothell Sep 9, 2026
e5bb899
fix: use signed-urls endpoint instead of signed-url to reduce network…
brandonbothell Sep 9, 2026
3195811
feat: enhance development environment setup and improve signed URL/jw…
brandonbothell Sep 9, 2026
6e907dc
fix: remove unused state
brandonbothell Sep 9, 2026
2279290
fix: remove JWT handling from FullLayout and streamline authFetch tok…
brandonbothell Sep 9, 2026
9cc3d23
refactor: combine styles.css's
brandonbothell Sep 10, 2026
0651fbd
feat: implement theme toggle component and associated styles
brandonbothell Sep 10, 2026
74e4662
fix: layout deletion button styles in light mode
brandonbothell Sep 10, 2026
d17f8c0
feat: add Lightbox toolbar components
brandonbothell Sep 10, 2026
5d2661c
feat: nickname saving
brandonbothell Sep 11, 2026
835b9a2
feat: implement StreamAddButton and RecordingRenamePopover components…
brandonbothell Sep 11, 2026
2233221
fix: update thumb styles based on viewport width
brandonbothell Sep 11, 2026
99070e2
fix: fix fullscreen button on incompatible clients, remove console lo…
brandonbothell Sep 11, 2026
1990710
fix: wrap App in StrictMode; strict mode/setState fixes
brandonbothell Sep 11, 2026
5ec786b
fix: lazy load CollapsedLightbox and StreamAddButton components for i…
brandonbothell Sep 11, 2026
d9cd5c2
fix: add scripts to prevent Firefox FOUC warnings
brandonbothell Sep 11, 2026
aca5f06
fix: comment formatting in index.html
brandonbothell Sep 12, 2026
de548f6
feat: implement CreateStreamPopover and StreamTypeControl components …
brandonbothell Sep 12, 2026
cfffdee
feat: update component styles for improved responsiveness and fix cre…
brandonbothell Sep 12, 2026
e88718d
feat: add in-layout stream edit menu UI
brandonbothell Sep 12, 2026
5695b21
feat: implement CreateEditStreamPopover component and update related …
brandonbothell Sep 12, 2026
f222a02
feat: enhance saveMotionSegmentsWithRetry and saveMotionSegments with…
brandonbothell Sep 13, 2026
afda9cf
fix(web): temporarily update page size until adoption of web2
brandonbothell Sep 13, 2026
c2fbed4
feat: center streams, add z-index to Aside and Footer components in F…
brandonbothell Sep 13, 2026
98f5f0d
fix: prevent unnecessary timeout handling in setupStreamMotionMonitor…
brandonbothell Sep 13, 2026
6a471ab
feat: implement delete confirmation modal in CreateEditStreamPopover …
brandonbothell Sep 13, 2026
4807315
fix: remove placeholder navbar header and footer
brandonbothell Sep 13, 2026
e9b65f1
chore: update dependencies and improve accessibility in stream compon…
brandonbothell Sep 17, 2026
438ea3c
feat: add autorotate to ffmpeg video input and stop random pausing of…
brandonbothell Sep 17, 2026
1ba3f55
feat: implement stream operation queuing and enhance state management…
brandonbothell Sep 17, 2026
0b7fbeb
feat: add autofocus to nickname input in RecordingRenamePopover and i…
brandonbothell Sep 18, 2026
e04499b
feat: enhance RecordingRenamePopover with focus management and improv…
brandonbothell Sep 18, 2026
99dfff9
feat: enhance Combobox in RecordingsPages with return focus and impro…
brandonbothell Sep 18, 2026
14a34d6
feat: simplify rendering logic in RecordingsPages and remove empty st…
brandonbothell Sep 18, 2026
aae68f8
feat: update error message in RecordingsPages and remove unused Text …
brandonbothell Sep 18, 2026
1080e29
feat: update @gfazioli/mantine-video dependency to version 1.2.2
brandonbothell Sep 18, 2026
3013cc8
feat: enhance StreamsGrid layout responsiveness with height adjustmen…
brandonbothell Sep 18, 2026
d53eb35
feat: add h264_metadata filter for Nvidia encoding and improve commen…
brandonbothell Sep 18, 2026
5a31646
fix: try using -noautorotate to prevent stream orientation drift with…
brandonbothell Sep 18, 2026
621f91b
fix: remove probably unnecessary bitstream filter from nvenc encoding
brandonbothell Sep 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ test_*.png
*.debug.png

# Recordings and media files
recordings/
hls_*/

# Thumbnails
Expand Down Expand Up @@ -171,3 +170,5 @@ Thumbs.db
*.bak
*.backup
android/app/debug/output-metadata.json

tsconfig.tsbuildinfo
11 changes: 10 additions & 1 deletion eslint.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,20 @@ export default defineConfig([
'.yarn/',
'android/',
'source/generated/',
'web2/dist/',
]),
{
extends: fixupConfigRules(compat.extends('plugin:import/recommended')),

files: ['source/**/*.ts', 'eslint.config.mts', 'scripts/**/*.ts', 'prisma.config.ts', 'capacitor.config.ts'],
files: [
'source/**/*.ts',
'eslint.config.mts',
'scripts/**/*.ts',
'prisma.config.ts',
'capacitor.config.ts',
'web2/**/*.ts',
'web2/**/*.tsx',
],

plugins: {
'@stylistic': stylistic,
Expand Down
16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"packageManager": "yarn@4.17.0",
"workspaces": [
".",
"web"
"web",
"web2"
],
"scripts": {
"start": "node ./compiled/camera.js",
Expand All @@ -17,19 +18,17 @@
"build:server": "yarn build:server:safe",
"build:server:safe": "copyfiles -u 1 \"source/generated/**/*\" \"compiled-temp\" && tsc --project tsconfig.build.json --outDir compiled-temp && tsx --tsconfig tsconfig.build.json scripts/safeReplace.ts",
"build:server:force": "copyfiles -u 1 \"source/generated/**/*\" \"compiled\" && tsc --project tsconfig.build.json",
"build:client": "dotenvx run -- yarn workspace web build",
"dev:client": "dotenvx run -- yarn workspace web vite build --watch",
"build:client": "dotenvx run -- yarn workspace web2 build",
"dev:client": "cross-env NODE_ENV=development dotenvx run -- yarn workspace web2 vite build --watch --mode development --minify false",
"dev:server": "nodemon --watch source --ext ts --exec \"tsx --tsconfig tsconfig.build.json ./source/camera.ts\"",
"db:generate": "yarn prisma generate",
"db:push": "yarn prisma db push",
"db:migrate:dev:reset": "yarn prisma migrate reset",
"db:migrate:dev": "yarn prisma migrate dev",
"db:migrate:resolve": "yarn prisma migrate resolve",
"db:migrate:deploy": "yarn prisma migrate deploy",
"lint": "yarn lint:server || true && yarn lint:client",
"lint:fix": "yarn lint:server --fix && yarn lint:client --fix",
"lint:server": "yarn eslint",
"lint:client": "yarn workspace web eslint",
"lint": "yarn eslint",
"lint:fix": "yarn lint --fix",
"format:server": "prettier --check source/",
"format:server:fix": "prettier --write source/",
"format:client": "prettier --check web/src",
Expand All @@ -50,12 +49,13 @@
"@types/express-session": "1.19.0",
"@types/greenlock-express": "4.0.3",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "25.6.0",
"@types/node": "25.9.7",
"@types/session-file-store": "1.2.6",
"@types/web-push": "3.6.4",
"@typescript-eslint/eslint-plugin": "8.63.0",
"@typescript-eslint/parser": "8.63.0",
"concurrently": "10.0.3",
"cross-env": "10.1.0",
"eslint": "10.3.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
Expand Down
152 changes: 82 additions & 70 deletions source/camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ export const notifyLogPath = path.join(
)
export const socketDisconnectMap = new Map<string, number>()

// Ensure logs directory exists
const motionLogsDir = path.dirname(motionLogPath)
if (!fs.existsSync(motionLogsDir)) {
fs.mkdirSync(motionLogsDir, { recursive: true })
Expand Down Expand Up @@ -108,6 +107,7 @@ export interface RequestWithUser extends express.Request {
}

const streamStates: Record<string, StreamMotionState | undefined> = {}
const streamStateInitializations = new Map<string, Promise<StreamMotionState>>()

export const motionWatcherTimeouts = new Map<string, NodeJS.Timeout>()

Expand All @@ -119,36 +119,7 @@ export async function setupStreamMotionMonitoring(streamId?: string) {
`[${streamId}] Monitoring started at ${new Date().toLocaleString()}`,
)

if (!streamStates[streamId]) {
const persistedStates = await loadPersistedStreamStates()
streamStates[streamId] = {
segmentTimestampMap: new Map(),
processingSegment: false,
notificationSent: false,
motionRecordingActive: false,
motionRecordingTimeoutAt: 0,
motionSegments: [],
flushingSegments: [],
recentSegments: [],
flushedSegments: [],
motionPaused: persistedStates[streamId]?.motionPaused ?? false,
startupTime: Date.now(),
savingInProgress: false,
currentSaveProcess: null,
saveRetryCount: 0,
motionStartedAt: 0,
lastSegmentProcessAt: 0,
recordingTitle: `motion_${new Date().toISOString().replace(/[:.]/g, '-')}.mp4`,
flushRecordings: [],
nextFlushNumber: 1,
cleaningUp: false,
cancelFlush: false,
lowSpaceNotified: false,
lastNotifiedRestartCooldownAt: 0,
currentRecordingMotionTimestamps: [],
lastPlaylistUpdatedAt: 0,
}
}
await initializeStreamState(streamId)

// --- Motion Detection Watcher ---
watchers.set(
Expand Down Expand Up @@ -186,6 +157,7 @@ export async function setupStreamMotionMonitoring(streamId?: string) {
'New segment observed while processing previous segment, rescheduling processing to avoid busy loop.',
)
setTimeout(() => processSegment(segmentPath), 300)
return
}

state.processingSegment = true
Expand Down Expand Up @@ -350,6 +322,10 @@ export async function setupStreamMotionMonitoring(streamId?: string) {
motionWatcherTimeouts.set(
streamId,
setTimeout(async () => {
if (!dynamicStreams[streamId]) {
motionWatcherTimeouts.delete(streamId)
return
}
if (
!streamStates[streamId] &&
!dynamicStreams[streamId].restartInProgress
Expand Down Expand Up @@ -658,7 +634,7 @@ loadStreamsFromDb()
if (process.env.API_ENV === 'production') return
setTimeout(() => {
open(`http://localhost:${port}`)
}, 1500)
}, 3000)
})
})

Expand Down Expand Up @@ -761,7 +737,9 @@ app.use(
cors({
origin: (origin, callback) => {
const allowedOrigins = (
process.env.API_ENV === 'production' ? [] : ['http://localhost:3000']
process.env.API_ENV === 'production'
? []
: [`http://localhost:${process.env.PORT ?? 3000}`]
).concat(config.domains ?? [])
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true)
Expand Down Expand Up @@ -855,23 +833,39 @@ const thumbnailLimiter = rateLimit({
legacyHeaders: false,
})

const staticResourceLimiter = rateLimit({
validate: { ip: false },
windowMs: 60 * 1000, // 1 minute
max: 240,
standardHeaders: true,
legacyHeaders: false,
})

// --- Static & API Routes ---
app.use(express.static(path.join(__dirname, '..', 'web', 'dist')))
app.use(express.static(path.join(__dirname, '..', 'web2', 'dist')))
app.use(
'/recordings/thumbnails',
thumbnailLimiter,
jwtAuth,
express.static(path.join(config.recordingsDirectory, 'thumbnails')),
)
app.get(/^\/(?!hls|api|recordings|signed|sounds).*/, (req, res) => {
res.sendFile(path.join(__dirname, '..', 'web', 'dist', 'index.html'))
})
app.get(
/^\/(?!hls|api|recordings|signed|sounds).*/,
staticResourceLimiter,
(_req, res) => {
res.sendFile(path.join(__dirname, '..', 'web2', 'dist', 'index.html'))
},
)

app.get(/^\/recordings(\/[^/]+)(\/[^/]+)?$/, (req, res) => {
res.sendFile(path.join(__dirname, '..', 'web', 'dist', 'index.html'))
})
app.get(
/^\/recordings(\/[^/]+)(\/[^/]+)?$/,
staticResourceLimiter,
(_req, res) => {
res.sendFile(path.join(__dirname, '..', 'web2', 'dist', 'index.html'))
},
)

app.get('/api/vapid-public-key', (req, res) => {
app.get('/api/vapid-public-key', (_req, res) => {
res.json({ publicKey: config.vapid.publicKey })
})

Expand Down Expand Up @@ -960,36 +954,54 @@ export async function createStreamManager(stream: {
}

export async function initializeStreamState(streamId: string) {
streamStates[streamId] = {
segmentTimestampMap: new Map(),
processingSegment: false,
notificationSent: false,
motionRecordingActive: false,
motionRecordingTimeoutAt: 0,
motionSegments: [],
flushingSegments: [],
recentSegments: [],
flushedSegments: [],
motionPaused:
(await loadPersistedStreamState(streamId))?.motionPaused ?? false,
startupTime: Date.now(),
savingInProgress: false,
currentSaveProcess: null,
saveRetryCount: 0,
motionStartedAt: 0,
lastSegmentProcessAt: 0,
recordingTitle: `motion_${new Date().toISOString().replace(/[:.]/g, '-')}.mp4`,
flushRecordings: [],
nextFlushNumber: 1,
cleaningUp: false,
cancelFlush: false,
lowSpaceNotified: false,
lastNotifiedRestartCooldownAt: 0,
currentRecordingMotionTimestamps: [],
lastPlaylistUpdatedAt: 0,
}
const existingState = streamStates[streamId]
if (existingState) return existingState

const existingInitialization = streamStateInitializations.get(streamId)
if (existingInitialization) return existingInitialization

const initialization = (async () => {
const state: StreamMotionState = {
segmentTimestampMap: new Map(),
processingSegment: false,
notificationSent: false,
motionRecordingActive: false,
motionRecordingTimeoutAt: 0,
motionSegments: [],
flushingSegments: [],
recentSegments: [],
flushedSegments: [],
motionPaused:
(await loadPersistedStreamState(streamId))?.motionPaused ?? false,
startupTime: Date.now(),
savingInProgress: false,
currentSaveProcess: null,
saveRetryCount: 0,
motionStartedAt: 0,
lastSegmentProcessAt: 0,
recordingTitle: `motion_${new Date().toISOString().replace(/[:.]/g, '-')}.mp4`,
flushRecordings: [],
nextFlushNumber: 1,
cleaningUp: false,
cancelFlush: false,
lowSpaceNotified: false,
lastNotifiedRestartCooldownAt: 0,
currentRecordingMotionTimestamps: [],
lastPlaylistUpdatedAt: 0,
}

// Publish once, after async persistence has completed, so every caller
// observes the same state object from this point onward.
streamStates[streamId] ??= state
return streamStates[streamId]!
})()

return streamStates[streamId]
streamStateInitializations.set(streamId, initialization)
try {
return await initialization
} finally {
streamStateInitializations.delete(streamId)
}
}

async function loadPersistedStreamState(
Expand Down
21 changes: 21 additions & 0 deletions source/hardwareCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const { execSync } = require('child_process')

let hasNvidiaHardware = false

try {
// Execute a fast probe that exits immediately without printing outputs to the console
// Works on both Windows and Linux environments
execSync('nvidia-smi --help', { stdio: 'ignore' })
hasNvidiaHardware = true
console.log(
'[System Probe] Nvidia hardware capability detected successfully.',
)
} catch (error) {
hasNvidiaHardware = false
console.log(
'[System Probe] Nvidia hardware not available. Falling back to CPU/Direct methods.',
)
}

// Export the live state variable
export const isNvidiaAvailable = () => hasNvidiaHardware
Loading
Loading