├── .python-version ├── skellycam ├── api │ ├── __init__.py │ ├── http │ │ ├── __init__.py │ │ ├── ui │ │ │ ├── __init__.py │ │ │ └── ui_router.py │ │ ├── app │ │ │ ├── __init__.py │ │ │ ├── health.py │ │ │ └── shutdown.py │ │ ├── cameras │ │ │ └── __init__.py │ │ └── videos │ │ │ ├── __init__.py │ │ │ └── videos_router.py │ ├── middleware │ │ ├── __init__.py │ │ └── cors.py │ ├── websocket │ │ ├── __init__.py │ │ └── websocket_connect.py │ ├── server_constants.py │ └── routers.py ├── system │ ├── __init__.py │ ├── diagnostics │ │ ├── __init__.py │ │ ├── find_optimal_chunk_size_for_pipe_transer.py │ │ └── numpy_array_copy_duration.py │ ├── device_detection │ │ ├── __init__.py │ │ └── detect_microphone_devices.py │ └── logging_configuration │ │ ├── __init__.py │ │ ├── filters │ │ ├── __init__.py │ │ └── delta_time.py │ │ ├── formatters │ │ ├── __init__.py │ │ ├── custom_formatter.py │ │ └── color_formatter.py │ │ ├── handlers │ │ ├── __init__.py │ │ └── colored_console.py │ │ ├── log_format_string.py │ │ ├── log_levels.py │ │ ├── package_log_quieters.py │ │ ├── log_test_messages.py │ │ ├── logging_color_helpers.py │ │ ├── configure_logging.py │ │ └── logger_builder.py ├── tests │ ├── __init__.py │ ├── test_recorders │ │ └── __init__.py │ ├── test_timestamps │ │ └── __init__.py │ ├── test_shared_memory_objects │ │ └── __init__.py │ └── test_descriptive_statistics.py ├── core │ ├── camera │ │ ├── __init__.py │ │ ├── config │ │ │ ├── __init__.py │ │ │ ├── image_rotation_types.py │ │ │ └── image_resolution.py │ │ └── opencv │ │ │ ├── __init__.py │ │ │ └── opencv_helpers │ │ │ ├── __init__.py │ │ │ ├── create_initial_frame_recarray.py │ │ │ ├── determine_backend.py │ │ │ ├── handle_video_recording_loop.py │ │ │ ├── opencv_extract_config.py │ │ │ ├── create_cv2_video_capture.py │ │ │ └── check_for_new_config.py │ ├── ipc │ │ ├── __init__.py │ │ ├── pubsub │ │ │ ├── __init__.py │ │ │ └── pubsub_abcs.py │ │ └── shared_memory │ │ │ ├── __init__.py │ │ │ ├── multi_frame_payload_single_slot_shared_memory.py │ │ │ ├── frame_payload_shared_memory_ring_buffer.py │ │ │ ├── camera_shared_memory_ring_buffer.py │ │ │ └── shared_memory_number.py │ ├── types │ │ ├── __init__.py │ │ └── type_overloads.py │ ├── __init__.py │ ├── recorders │ │ ├── __init__.py │ │ ├── audio │ │ │ └── __init__.py │ │ ├── videos │ │ │ └── __init__.py │ │ └── recording_manager_status.py │ ├── camera_group │ │ ├── __init__.py │ │ └── timestamps │ │ │ ├── __init__.py │ │ │ └── numpy_timestamps │ │ │ ├── __init__.py │ │ │ ├── save_timestamps_statistics_summary.py │ │ │ ├── process_recording_timestamps.py │ │ │ └── calculate_frame_timestamp_statistics.py │ └── device_detection │ │ ├── __init__.py │ │ └── detect_microphone_devices.py ├── utilities │ ├── __init__.py │ ├── get_version.py │ ├── arbitrary_types_base_model.py │ ├── create_camera_group_id.py │ ├── clean_path.py │ ├── check_shutdown_flag.py │ ├── cross_platform_start_file.py │ ├── rotate_image.py │ ├── setup_windows_app_id.py │ ├── clean_up_empty_directories.py │ ├── time_unit_conversion.py │ ├── get_process_memory_useage.py │ ├── find_available_port.py │ ├── kill_process_on_port.py │ ├── check_main_processs_heartbeat.py │ ├── check_ffmpeg_and_h264_codec.py │ ├── wait_functions.py │ └── active_elements_check.py └── __init__.py ├── skellycam-ui ├── src │ ├── services │ │ ├── index.ts │ │ ├── electron-ipc │ │ │ ├── index.ts │ │ │ └── electron-ipc.ts │ │ └── server │ │ │ ├── index.ts │ │ │ └── server-helpers │ │ │ ├── server-urls.ts │ │ │ └── frame-processor │ │ │ └── frame-processor.ts │ ├── store │ │ ├── slices │ │ │ ├── theme │ │ │ │ ├── theme-types.ts │ │ │ │ ├── index.ts │ │ │ │ ├── theme-selectors.ts │ │ │ │ └── theme-slice.ts │ │ │ ├── log-records │ │ │ │ ├── index.ts │ │ │ │ ├── logs-types.ts │ │ │ │ └── log-records-slice.ts │ │ │ ├── recording │ │ │ │ ├── index.ts │ │ │ │ ├── recording-types.ts │ │ │ │ ├── recording-slice.ts │ │ │ │ └── recording-thunks.ts │ │ │ ├── framerate │ │ │ │ ├── index.ts │ │ │ │ ├── framerate-types.ts │ │ │ │ └── framerate-slice.ts │ │ │ ├── videos │ │ │ │ ├── index.ts │ │ │ │ ├── videos-types.ts │ │ │ │ ├── videos-selectors.ts │ │ │ │ └── videos-thunks.ts │ │ │ └── cameras │ │ │ │ └── index.ts │ │ ├── hooks.ts │ │ ├── types.ts │ │ ├── thunks │ │ │ ├── server-healthcheck.ts │ │ │ ├── shutdown-server.ts │ │ │ ├── pause-unpause-thunk.ts │ │ │ ├── close-cameras-thunks.ts │ │ │ ├── update-camera-configs-thunk.ts │ │ │ └── connect-to-cameras-thunk.ts │ │ ├── index.ts │ │ └── store.ts │ ├── demos │ │ ├── ipc.ts │ │ └── node.ts │ ├── vite-env.d.ts │ ├── main.tsx │ ├── components │ │ ├── update │ │ │ ├── update.css │ │ │ ├── Progress │ │ │ │ ├── progress.css │ │ │ │ └── index.tsx │ │ │ ├── README.zh-CN.md │ │ │ ├── README.md │ │ │ └── Modal │ │ │ │ ├── modal.css │ │ │ │ └── index.tsx │ │ ├── ui-components │ │ │ ├── Footer.tsx │ │ │ ├── Header.tsx │ │ │ ├── ThemeToggle.tsx │ │ │ └── BottomPanelContent.tsx │ │ ├── camera-config-tree-view │ │ │ ├── NoCamerasPlaceholder.tsx │ │ │ ├── CameraConfigTreeSection.tsx │ │ │ └── CameraGroupTreeItem.tsx │ │ ├── recording-info-panel │ │ │ └── recording-subcomponents │ │ │ │ ├── RecordingNamePreview.tsx │ │ │ │ └── DelayRecordingStartControl.tsx │ │ ├── common │ │ │ └── ErrorBoundary.tsx │ │ ├── camera-view-settings-overlay │ │ │ └── ImageScaleSlider.tsx │ │ ├── camera-config-panel │ │ │ └── CameraConfigRotation.tsx │ │ ├── PauseUnpauseButton.tsx │ │ └── camera-views │ │ │ └── CameraViewsGrid.tsx │ ├── App.tsx │ ├── layout │ │ ├── BaseContentRouter.tsx │ │ ├── AppContent.tsx │ │ ├── paperbase_theme │ │ │ └── PaperbaseContent.tsx │ │ └── BasePanelLayout.tsx │ ├── App.css │ ├── hooks │ │ └── useAppUrls.ts │ ├── pages │ │ ├── CamerasPage.tsx │ │ └── VideosPage.tsx │ └── index.css ├── public │ └── skellycam-logo.png ├── e2e │ ├── screenshots │ │ └── example.png │ └── example.spec.ts ├── old_electron │ ├── main │ │ ├── helpers │ │ │ ├── app-environment.ts │ │ │ ├── logger.ts │ │ │ ├── window-manager.ts │ │ │ └── app-paths.ts │ │ └── index.ts │ └── electron-env.d.ts ├── .npmrc ├── electron │ ├── preload │ │ └── index.ts │ └── main │ │ ├── services │ │ ├── logger.ts │ │ └── update-handler.ts │ │ ├── ipc.ts │ │ ├── app-paths.ts │ │ └── index.ts ├── tsconfig.node.json ├── index.html ├── .gitignore ├── tsconfig.json ├── electron-builder.json ├── playwright.config.ts ├── package.json └── vite.config.flat.txt ├── .env.sample ├── shared └── skellycam-logo │ ├── skellycam-logo.ai │ ├── skellycam-logo.png │ └── skellycam-favicon.ico ├── scripts └── save-env-to-workspace.ps1 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── help_wanted.md ├── PULL_REQUEST_TEMPLATE.md └── dependabot.yml ├── installers └── nuitka_scripts │ ├── skellycam-nuitka.config.yml │ └── nuitka_installer_notes.md └── Dockerfile /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /skellycam/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/system/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/api/http/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/api/http/ui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/core/camera/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/core/ipc/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/core/types/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/api/http/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/api/http/cameras/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/api/http/videos/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/api/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/api/websocket/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/core/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /skellycam/core/ipc/pubsub/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/core/recorders/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/core/camera/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/core/camera/opencv/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/core/camera_group/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/core/device_detection/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/core/recorders/audio/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/core/recorders/videos/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/system/diagnostics/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/tests/test_recorders/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/tests/test_timestamps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/core/ipc/shared_memory/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/system/device_detection/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/core/camera_group/timestamps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/system/logging_configuration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/core/camera/opencv/opencv_helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/system/logging_configuration/filters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/tests/test_shared_memory_objects/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/system/logging_configuration/formatters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/system/logging_configuration/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam/core/camera_group/timestamps/numpy_timestamps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skellycam-ui/src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './electron-ipc' 2 | export * from './server' 3 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/theme/theme-types.ts: -------------------------------------------------------------------------------- 1 | export type ThemeMode = 'light' | 'dark'; 2 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | AZURE_TENANT_ID=your-tenant-id 2 | AZURE_CLIENT_ID=your-client-id 3 | AZURE_CLIENT_SECRET=your-client-secret -------------------------------------------------------------------------------- /shared/skellycam-logo/skellycam-logo.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freemocap/skellycam/HEAD/shared/skellycam-logo/skellycam-logo.ai -------------------------------------------------------------------------------- /skellycam-ui/public/skellycam-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freemocap/skellycam/HEAD/skellycam-ui/public/skellycam-logo.png -------------------------------------------------------------------------------- /shared/skellycam-logo/skellycam-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freemocap/skellycam/HEAD/shared/skellycam-logo/skellycam-logo.png -------------------------------------------------------------------------------- /skellycam-ui/e2e/screenshots/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freemocap/skellycam/HEAD/skellycam-ui/e2e/screenshots/example.png -------------------------------------------------------------------------------- /shared/skellycam-logo/skellycam-favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freemocap/skellycam/HEAD/shared/skellycam-logo/skellycam-favicon.ico -------------------------------------------------------------------------------- /skellycam/api/server_constants.py: -------------------------------------------------------------------------------- 1 | PROTOCOL = "http" 2 | HOSTNAME = "localhost" 3 | PORT = 53117 4 | APP_URL = f"{PROTOCOL}://{HOSTNAME}:{PORT}" -------------------------------------------------------------------------------- /skellycam-ui/src/services/electron-ipc/index.ts: -------------------------------------------------------------------------------- 1 | // services/index.ts 2 | export * from './electron-ipc' 3 | export * from './electron-ipc-client' 4 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/theme/index.ts: -------------------------------------------------------------------------------- 1 | export * from './theme-slice'; 2 | export * from './theme-types'; 3 | export * from './theme-selectors'; 4 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/log-records/index.ts: -------------------------------------------------------------------------------- 1 | export * from './log-records-slice'; 2 | export * from './logs-types'; 3 | export * from './logs-selectors'; 4 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/recording/index.ts: -------------------------------------------------------------------------------- 1 | export * from './recording-slice'; 2 | export * from './recording-types'; 3 | export * from './recording-thunks'; 4 | -------------------------------------------------------------------------------- /skellycam-ui/src/demos/ipc.ts: -------------------------------------------------------------------------------- 1 | window.ipcRenderer.on('main-process-message', (_event, ...args) => { 2 | console.log('[Receive Main-process message]:', ...args) 3 | }) 4 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/framerate/index.ts: -------------------------------------------------------------------------------- 1 | export * from './framerate-slice'; 2 | export * from './framerate-types'; 3 | export * from './framerate-selectors'; 4 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/videos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './videos-slice'; 2 | export * from './videos-types'; 3 | export * from './videos-thunks'; 4 | export * from './videos-selectors'; 5 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/cameras/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cameras-slice'; 2 | export * from './cameras-types'; 3 | export * from './cameras-selectors'; 4 | export * from './cameras-thunks'; 5 | -------------------------------------------------------------------------------- /skellycam-ui/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface Window { 4 | // expose in the `electron/preload/index.ts` 5 | ipcRenderer: import('electron').IpcRenderer 6 | } 7 | -------------------------------------------------------------------------------- /skellycam/utilities/get_version.py: -------------------------------------------------------------------------------- 1 | from skellycam import __version__ 2 | 3 | 4 | def get_version() -> str: 5 | return __version__ 6 | 7 | 8 | if __name__ == "__main__": 9 | print(get_version()) 10 | -------------------------------------------------------------------------------- /scripts/save-env-to-workspace.ps1: -------------------------------------------------------------------------------- 1 | # build.ps1 2 | Get-Content .env | ForEach-Object { 3 | $name, $value = $_.split('=') 4 | if ($name -and $value) { 5 | Set-Item -Path env:$name -Value $value 6 | } 7 | } 8 | 9 | -------------------------------------------------------------------------------- /skellycam/utilities/arbitrary_types_base_model.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ConfigDict 2 | 3 | 4 | class ArbitraryTypesBaseModel(BaseModel): 5 | model_config = ConfigDict( 6 | arbitrary_types_allowed=True 7 | ) 8 | -------------------------------------------------------------------------------- /skellycam-ui/src/demos/node.ts: -------------------------------------------------------------------------------- 1 | import {lstat} from 'node:fs/promises' 2 | import {cwd} from 'node:process' 3 | 4 | lstat(cwd()).then(stats => { 5 | console.log('[fs.lstat]', stats) 6 | }).catch(err => { 7 | console.error(err) 8 | }) 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: 🐞 Bug report 4 | about: Create a report to help us improve 5 | title: "[Bug] the title of bug report" 6 | labels: bug 7 | assignees: '' 8 | 9 | --- 10 | 11 | #### Describe the bug 12 | -------------------------------------------------------------------------------- /skellycam-ui/old_electron/main/helpers/app-environment.ts: -------------------------------------------------------------------------------- 1 | // Export the ENV_CONFIG object using the process.env values 2 | export const APP_ENVIRONMENT = { 3 | IS_DEV: process.env.NODE_ENV === 'development', 4 | SHOULD_LAUNCH_PYTHON: true, 5 | }; 6 | -------------------------------------------------------------------------------- /skellycam/api/routers.py: -------------------------------------------------------------------------------- 1 | from skellycam.api.http.cameras.camera_router import camera_router 2 | from skellycam.api.websocket.websocket_connect import websocket_router 3 | 4 | SKELLYCAM_ROUTERS = [ 5 | websocket_router, 6 | camera_router, 7 | ] 8 | -------------------------------------------------------------------------------- /skellycam-ui/.npmrc: -------------------------------------------------------------------------------- 1 | # For electron-builder 2 | # https://github.com/electron-userland/electron-builder/issues/6289#issuecomment-1042620422 3 | shamefully-hoist=true 4 | 5 | # For China 🇨🇳 developers 6 | # electron_mirror=https://npmmirror.com/mirrors/electron/ 7 | -------------------------------------------------------------------------------- /skellycam/utilities/create_camera_group_id.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from skellycam.core.types.type_overloads import CameraGroupIdString 4 | 5 | 6 | def create_camera_group_id() -> CameraGroupIdString: 7 | return str(uuid.uuid4())[:6] # Shortened UUID for readability 8 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/theme/theme-selectors.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from '../../types'; 2 | 3 | export const selectThemeMode = (state: RootState) => state.theme.mode; 4 | export const selectSystemThemePreference = (state: RootState) => 5 | state.theme.systemPreference; 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/help_wanted.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🥺 Help wanted 3 | about: Confuse about the use of electron-vue-vite 4 | title: "[Help] the title of help wanted report" 5 | labels: help wanted 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Describe the problem you confuse 11 | -------------------------------------------------------------------------------- /skellycam-ui/electron/preload/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { contextBridge, ipcRenderer } from 'electron'; 3 | 4 | // Expose a simple tRPC bridge 5 | contextBridge.exposeInMainWorld('electronAPI', { 6 | invoke: (path: string, input?: any) => 7 | ipcRenderer.invoke('trpc', { path, input }) 8 | }); 9 | -------------------------------------------------------------------------------- /installers/nuitka_scripts/skellycam-nuitka.config.yml: -------------------------------------------------------------------------------- 1 | - module-name: 'skellycam.api.http.ui' 2 | data-files: 3 | patterns: 4 | - 'skellycam/api/http/ui/ui.html' 5 | 6 | - module-name: 'shared.skellycam-logo' 7 | data-files: 8 | patterns: 9 | - 'shared/skellycam-logo/skellycam-favicon.ico' 10 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/hooks.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; 2 | import type { RootState, AppDispatch } from './types'; 3 | 4 | export const useAppDispatch = (): AppDispatch => useDispatch(); 5 | export const useAppSelector: TypedUseSelectorHook = useSelector; 6 | -------------------------------------------------------------------------------- /skellycam/utilities/clean_path.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def clean_path(path: str) -> str: 5 | """ 6 | Replace the home directory with a tilde in the path 7 | """ 8 | home_dir = str(Path.home()) 9 | if home_dir in path: 10 | return path.replace(home_dir, "~") 11 | return path 12 | -------------------------------------------------------------------------------- /skellycam-ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "resolveJsonModule": true, 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": [ 10 | "vite.config.ts", 11 | "package.json" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /skellycam/utilities/check_shutdown_flag.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def get_server_shutdown_environment_flag(): 5 | flag = os.getenv("SKELLYCAM_SHOULD_SHUTDOWN") 6 | if flag: 7 | for _ in range(20): 8 | print(f"SHUTDOWN FLAG DETECTED! os.getenv('SKELLYCAM_SHOULD_SHUTDOWN'): {flag}") 9 | return flag 10 | -------------------------------------------------------------------------------- /skellycam/utilities/cross_platform_start_file.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | 5 | 6 | def open_file(filename: str): 7 | if sys.platform == "win32": 8 | os.startfile(filename) 9 | else: 10 | opener = "open" if sys.platform == "darwin" else "xdg-open" 11 | subprocess.call([opener, filename]) 12 | -------------------------------------------------------------------------------- /skellycam/api/middleware/cors.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.middleware.cors import CORSMiddleware 3 | 4 | 5 | def cors(app: FastAPI) -> None: 6 | app.add_middleware( 7 | CORSMiddleware, 8 | allow_origins=["*"], 9 | allow_credentials=True, 10 | allow_methods=["*"], 11 | allow_headers=["*"], 12 | ) 13 | -------------------------------------------------------------------------------- /skellycam-ui/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | 5 | import './index.css' 6 | 7 | 8 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 9 | 10 | 11 | , 12 | ) 13 | 14 | postMessage({payload: 'removeLoading'}, '*') 15 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/types.ts: -------------------------------------------------------------------------------- 1 | import { store } from './store'; 2 | import { ThunkAction, Action } from '@reduxjs/toolkit'; 3 | 4 | export type RootState = ReturnType; 5 | export type AppDispatch = typeof store.dispatch; 6 | export type AppThunk = ThunkAction< 7 | ReturnType, 8 | RootState, 9 | unknown, 10 | Action 11 | >; 12 | -------------------------------------------------------------------------------- /skellycam/api/http/app/health.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import APIRouter 4 | 5 | logger = logging.getLogger(__name__) 6 | health_router = APIRouter() 7 | 8 | 9 | @health_router.get("/health", summary="Hello👋", tags=['App']) 10 | def healthcheck_endpoint(): 11 | """ 12 | A simple endpoint to show the server is alive and happy 13 | """ 14 | 15 | return "Hello👋" 16 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### Description 4 | 5 | 6 | 7 | ### What is the purpose of this pull request? 8 | 9 | - [ ] Bug fix 10 | - [ ] New Feature 11 | - [ ] Documentation update 12 | - [ ] Other 13 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/recording/recording-types.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const RecordingInfoSchema = z.object({ 4 | isRecording: z.boolean(), 5 | recordingDirectory: z.string(), 6 | recordingName: z.string().nullable(), 7 | startedAt: z.string().nullable(), 8 | duration: z.number().nullable() 9 | }); 10 | 11 | export type RecordingInfo = z.infer; 12 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/thunks/server-healthcheck.ts: -------------------------------------------------------------------------------- 1 | import {useAppUrls} from "@/hooks/useAppUrls"; 2 | 3 | export const serverHealthcheck = async () => { 4 | const url = useAppUrls.getHttpEndpointUrls().health; 5 | const response = await fetch(url, {method: 'GET'}); 6 | 7 | if (!response.ok) { 8 | throw new Error(`HTTP error! status: ${response.status}`); 9 | } 10 | 11 | return response; 12 | }; 13 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/thunks/shutdown-server.ts: -------------------------------------------------------------------------------- 1 | import {useAppUrls} from "@/hooks/useAppUrls"; 2 | 3 | export const shutdownServer = async () => { 4 | const url = useAppUrls.getHttpEndpointUrls().shutdown; 5 | const response = await fetch(url, {method: 'GET'}); 6 | 7 | if (!response.ok) { 8 | throw new Error(`HTTP error! status: ${response.status}`); 9 | } 10 | 11 | return response; 12 | }; 13 | -------------------------------------------------------------------------------- /skellycam-ui/e2e/example.spec.ts: -------------------------------------------------------------------------------- 1 | import {_electron as electron, expect, test} from "@playwright/test"; 2 | 3 | test("homepage has title and links to intro page", async () => { 4 | const app = await electron.launch({args: [".", "--no-sandbox"]}); 5 | const page = await app.firstWindow(); 6 | expect(await page.title()).toBe("Electron + Vite + React"); 7 | await page.screenshot({path: "e2e/screenshots/example.png"}); 8 | }); 9 | -------------------------------------------------------------------------------- /skellycam/utilities/rotate_image.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | 4 | from skellycam.core.camera.config.image_rotation_types import RotationTypes 5 | 6 | 7 | def rotate_image(image:np.ndarray, rotation: RotationTypes): 8 | 9 | # Rotate the image if needed 10 | if rotation.value != -1: 11 | rotated_image = cv2.rotate(image, rotation.value) 12 | else: 13 | rotated_image = image 14 | 15 | return rotated_image 16 | -------------------------------------------------------------------------------- /skellycam-ui/src/services/server/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ServerContextProvider' 2 | export * from '@/services/server/server-helpers/frame-processor/binary-frame-parser' 3 | export * from './server-helpers/canvas-manager' 4 | export * from '@/services/server/server-helpers/frame-processor/frame-processor' 5 | export * from './server-helpers/offscreen-renderer.worker' 6 | export * from './server-helpers/server-urls' 7 | export * from './server-helpers/websocket-connection' 8 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/thunks/pause-unpause-thunk.ts: -------------------------------------------------------------------------------- 1 | import {useAppUrls} from "@/hooks/useAppUrls"; 2 | 3 | export const pauseUnpauseThunk = async () => { 4 | const pauseUnpauseUrl = useAppUrls.getHttpEndpointUrls().pauseUnpauseCameras; 5 | const response = await fetch(pauseUnpauseUrl, {method: 'GET'}); 6 | 7 | if (!response.ok) { 8 | throw new Error(`HTTP error! status: ${response.status}`); 9 | } 10 | 11 | return response; 12 | }; 13 | -------------------------------------------------------------------------------- /skellycam/utilities/setup_windows_app_id.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | def setup_app_id_for_windows(): 5 | if sys.platform == "win32": 6 | # set up so you can change the taskbar icon - https://stackoverflow.com/a/74531530/14662833 7 | import ctypes 8 | import skellycam 9 | 10 | myappid = f"{skellycam.__package_name__}_{skellycam.__version__}" # arbitrary string 11 | ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) 12 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/update/update.css: -------------------------------------------------------------------------------- 1 | .modal-slot { 2 | .update-progress { 3 | display: flex; 4 | } 5 | 6 | .new-version__target, 7 | .update__progress { 8 | margin-left: 40px; 9 | } 10 | 11 | .progress__title { 12 | margin-right: 10px; 13 | } 14 | 15 | .progress__bar { 16 | width: 0; 17 | flex-grow: 1; 18 | } 19 | 20 | .can-not-available { 21 | padding: 20px; 22 | text-align: center; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /skellycam/core/camera/config/image_rotation_types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | import cv2 4 | 5 | 6 | class RotationTypes(Enum): 7 | NO_ROTATION = -1 8 | CLOCKWISE_90 = cv2.ROTATE_90_CLOCKWISE 9 | ROTATE_180 = cv2.ROTATE_180 10 | COUNTERCLOCKWISE_90 = cv2.ROTATE_90_COUNTERCLOCKWISE 11 | 12 | def rotation_int_to_name(rotation_int: int) -> str: 13 | for rotation in RotationTypes: 14 | if rotation.value == rotation_int: 15 | return rotation.name 16 | return "UNKNOWN_ROTATION" 17 | -------------------------------------------------------------------------------- /skellycam-ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | SkellyCam💀📸 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /skellycam-ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Provider} from 'react-redux'; 3 | import {store} from '@/store'; 4 | import {ServerContextProvider} from "@/services/server/ServerContextProvider"; 5 | import {AppContent} from "@/layout/AppContent"; 6 | 7 | 8 | function App() { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | 18 | export default App; 19 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/index.ts: -------------------------------------------------------------------------------- 1 | export { store } from './store'; 2 | export type { RootState, AppDispatch, AppThunk } from './types'; 3 | export { useAppDispatch, useAppSelector } from './hooks'; 4 | 5 | 6 | // Re-export all slice actions and selectors for a nice 'barrel' design pattern, which makes imports cleaner 7 | export * from './slices/cameras'; 8 | export * from './slices/recording'; 9 | export * from './slices/framerate'; 10 | export * from './slices/log-records'; 11 | export * from './slices/theme'; 12 | export * from './slices/videos'; 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /skellycam/utilities/clean_up_empty_directories.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | def recursively_remove_empty_directories(base_path: str) -> None: 8 | logger.debug(f"Cleaning up empty directories...") 9 | for directory in reversed(list(Path(base_path).glob("**"))): 10 | if directory.is_dir(): 11 | try: 12 | directory.rmdir() # Remove directories 13 | except OSError: 14 | pass # Directory not empty, can't be removed 15 | -------------------------------------------------------------------------------- /skellycam-ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | dist-electron 14 | release 15 | *.local 16 | 17 | # Editor directories and files 18 | .vscode/.debug.env 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | #lockfile 28 | package-lock.json 29 | pnpm-lock.yaml 30 | yarn.lock 31 | /test-results/ 32 | /playwright-report/ 33 | /playwright/.cache/ 34 | 35 | *.exe 36 | pyapp-* 37 | *.zip -------------------------------------------------------------------------------- /skellycam-ui/src/components/update/Progress/progress.css: -------------------------------------------------------------------------------- 1 | .update-progress { 2 | display: flex; 3 | align-items: center; 4 | 5 | .update-progress-pr { 6 | border: 1px solid #000; 7 | border-radius: 3px; 8 | height: 6px; 9 | width: 300px; 10 | } 11 | 12 | .update-progress-rate { 13 | height: 6px; 14 | border-radius: 3px; 15 | background-image: linear-gradient(to right, rgb(130, 86, 208) 0%, var(--primary-color) 100%) 16 | } 17 | 18 | .update-progress-num { 19 | margin: 0 10px; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /skellycam/system/logging_configuration/filters/delta_time.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | 4 | 5 | class DeltaTimeFilter(logging.Filter): 6 | """Adds Δt since last log to records""" 7 | 8 | def __init__(self): 9 | self.prev_time = datetime.now().timestamp() 10 | super().__init__() 11 | 12 | def filter(self, record: logging.LogRecord) -> bool: 13 | current_time = datetime.now().timestamp() 14 | record.delta_t = f"{(current_time - self.prev_time) * 1000:.3f}ms" 15 | self.prev_time = current_time 16 | return True -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/videos/videos-types.ts: -------------------------------------------------------------------------------- 1 | export interface VideoFile { 2 | name: string; 3 | path: string; 4 | size?: number; 5 | duration?: number; 6 | thumbnail?: string; 7 | } 8 | 9 | export interface VideosState { 10 | folder: string; 11 | files: VideoFile[]; 12 | selectedFile: VideoFile | null; 13 | isLoading: boolean; 14 | error: string | null; 15 | playbackState: { 16 | isPlaying: boolean; 17 | currentTime: number; 18 | duration: number; 19 | volume: number; 20 | playbackRate: number; 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /skellycam/system/logging_configuration/formatters/custom_formatter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | 4 | 5 | class CustomFormatter(logging.Formatter): 6 | """Base formatter with microsecond timestamps and structured formatting""" 7 | 8 | def __init__(self, format_string: str): 9 | super().__init__(fmt=format_string, datefmt="%Y-%m-%dT%H:%M:%S") 10 | self.format_string = format_string 11 | 12 | def formatTime(self, record: logging.LogRecord, datefmt: str = None) -> str: 13 | return datetime.fromtimestamp(record.created).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] -------------------------------------------------------------------------------- /skellycam/system/logging_configuration/handlers/colored_console.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from ..filters.delta_time import DeltaTimeFilter 5 | from ..formatters.color_formatter import ColorFormatter 6 | from ..log_format_string import COLOR_LOG_FORMAT_STRING 7 | 8 | 9 | class ColoredConsoleHandler(logging.StreamHandler): 10 | """Colorized console output with Δt and process/thread coloring""" 11 | 12 | def __init__(self, stream=sys.stdout): 13 | super().__init__(stream) 14 | self.setFormatter(ColorFormatter(COLOR_LOG_FORMAT_STRING)) 15 | self.addFilter(DeltaTimeFilter()) -------------------------------------------------------------------------------- /skellycam-ui/old_electron/electron-env.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface ProcessEnv { 3 | VSCODE_DEBUG?: 'true' 4 | /** 5 | * The built directory structure 6 | * 7 | * ```tree 8 | * ├─┬ dist-electron 9 | * │ ├─┬ main 10 | * │ │ └── index.js > Electron-Main 11 | * │ └─┬ preload 12 | * │ └── index.mjs > Preload-Scripts 13 | * ├─┬ dist 14 | * │ └── index.html > Electron-Renderer 15 | * ``` 16 | */ 17 | APP_ROOT: string 18 | /** /dist/ or /public/ */ 19 | VITE_PUBLIC: string 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /skellycam/system/logging_configuration/log_format_string.py: -------------------------------------------------------------------------------- 1 | LOG_POINTER_STRING = "└>>" 2 | LOG_FORMAT_STRING_WO_PID_TID = LOG_POINTER_STRING + ( 3 | " %(message)s | " 4 | " %(levelname)s | " 5 | " %(delta_t)s | " 6 | " %(name)s.%(funcName)s():%(lineno)s | " 7 | " %(asctime)s | " 8 | ) 9 | 10 | LOG_FORMAT_STRING = LOG_FORMAT_STRING_WO_PID_TID + ( 11 | " PID:%(process)d:%(processName)s | " 12 | " TID:%(thread)d:%(threadName)s" 13 | ) 14 | 15 | COLOR_LOG_FORMAT_STRING = LOG_FORMAT_STRING_WO_PID_TID + ( 16 | " %(pid_color)sPID:%(process)d:%(processName)s\033[0m | " 17 | " %(tid_color)sTID:%(thread)d:%(threadName)s\033[0m" 18 | ) 19 | -------------------------------------------------------------------------------- /skellycam/utilities/time_unit_conversion.py: -------------------------------------------------------------------------------- 1 | from tzlocal import get_localzone 2 | 3 | def ns_to_ms(ns: int|float) -> float: 4 | """ 5 | Convert nanoseconds to milliseconds. 6 | """ 7 | return ns / 1e6 8 | 9 | def ms_to_ns(ms: float) -> int: 10 | """ 11 | Convert milliseconds to nanoseconds. 12 | """ 13 | return int(ms * 1e6) 14 | 15 | def ns_to_sec(ns: int|float) -> float: 16 | """ 17 | Convert nanoseconds to seconds. 18 | """ 19 | return ns / 1e9 20 | 21 | def ms_to_sec(ms: float) -> float: 22 | """ 23 | Convert milliseconds to seconds. 24 | """ 25 | return ms / 1000.0 26 | LOCAL_TIMEZONE = get_localzone() -------------------------------------------------------------------------------- /skellycam/utilities/get_process_memory_useage.py: -------------------------------------------------------------------------------- 1 | def get_memory_usage(): 2 | """Return current memory usage of this process in a human-readable format.""" 3 | import os 4 | import psutil 5 | 6 | # Get the current process 7 | process = psutil.Process(os.getpid()) 8 | 9 | # Get memory info in bytes 10 | memory_info = process.memory_info() 11 | 12 | # Convert to MB for readability 13 | rss_mb = memory_info.rss / (1024 * 1024) 14 | vms_mb = memory_info.vms / (1024 * 1024) 15 | 16 | return f"RSS: {rss_mb:.2f} MB, VMS: {vms_mb:.2f} MB" 17 | 18 | if __name__ == "__main__": 19 | print("Current process memory usage:") 20 | print(get_memory_usage()) -------------------------------------------------------------------------------- /skellycam-ui/src/layout/BaseContentRouter.tsx: -------------------------------------------------------------------------------- 1 | // skellycam-ui/src/layout/BaseContent.tsx 2 | import React from 'react'; 3 | import {Navigate, Route, Routes} from 'react-router-dom'; 4 | import {CamerasPage} from "@/pages/CamerasPage"; 5 | import VideosPage from "@/pages/VideosPage"; 6 | import WelcomePage from "@/pages/WelcomePage"; 7 | 8 | export const BaseContentRouter: React.FC = () => { 9 | return ( 10 | 11 | } /> 12 | }/> 13 | }/> 14 | }/> 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/update/Progress/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './progress.css' 3 | 4 | const Progress: React.FC> = props => { 7 | const {percent = 0} = props 8 | 9 | return ( 10 | 11 | 12 | 16 | 17 | {(percent ?? 0).toString().substring(0, 4)}% 18 | 19 | ) 20 | } 21 | 22 | export default Progress 23 | -------------------------------------------------------------------------------- /skellycam/api/websocket/websocket_connect.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | 4 | from fastapi import APIRouter, WebSocket, Request 5 | 6 | from skellycam.api.websocket.websocket_server import WebsocketServer 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | websocket_router = APIRouter(tags=["Websocket"], prefix="/websocket") 11 | 12 | 13 | @websocket_router.websocket("/connect") 14 | async def websocket_server_connect(websocket: WebSocket): 15 | await websocket.accept() 16 | app = websocket.scope["app"] 17 | logger.success(f"Websocket connection established at url: {websocket.url}") 18 | async with WebsocketServer(websocket=websocket, 19 | app=app) as websocket_server: 20 | await websocket_server.run() 21 | logger.info("Websocket closed") 22 | -------------------------------------------------------------------------------- /skellycam-ui/src/services/electron-ipc/electron-ipc.ts: -------------------------------------------------------------------------------- 1 | 2 | // Check if running in Electron 3 | import {electronIpcClient} from "@/services/electron-ipc/electron-ipc-client"; 4 | 5 | export const isElectron = (): boolean => { 6 | return typeof window !== 'undefined' && !!window.electronAPI; 7 | }; 8 | 9 | // Export the API client or null if not in Electron 10 | export const electronIpc = isElectron() ? electronIpcClient : null; 11 | 12 | // Type-safe wrapper hook for React components 13 | import { useMemo } from 'react'; 14 | 15 | export function useElectronIPC() { 16 | const api = useMemo(() => { 17 | if (!isElectron()) return null; 18 | return electronIpcClient; 19 | }, []); 20 | 21 | return { 22 | isElectron: isElectron(), 23 | api, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /skellycam/system/logging_configuration/log_levels.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from enum import Enum 3 | 4 | 5 | class LogLevels(Enum): 6 | ALL = logging.NOTSET # 0 # All logs, including those from third-party libraries 7 | LOOP = 3 # For logs that are printed in a loop (ONLY FOR DEBUGGING, REMOVE BEFORE PRODUCTION) 8 | TRACE = 5 # Low level logs for deep debugging 9 | DEBUG = logging.DEBUG # 10 # Detailed information for devs and curious folk 10 | INFO = logging.INFO # 20 # General information about the program 11 | SUCCESS = logging.INFO + 2 # 22 # OMG, something worked! 12 | API = logging.INFO + 5 # 25 # API calls/responses 13 | WARNING = logging.WARNING # 30 # Something unexpected happened, but it's necessarily an error 14 | ERROR = logging.ERROR # 40 # Something went wrong! 15 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/store.ts: -------------------------------------------------------------------------------- 1 | import {configureStore} from "@reduxjs/toolkit"; 2 | import {framerateSlice} from "./slices/framerate/framerate-slice"; 3 | import {cameraSlice} from "./slices/cameras/cameras-slice"; 4 | import {recordingSlice} from "./slices/recording/recording-slice"; 5 | import {themeSlice} from "./slices/theme/theme-slice"; 6 | import {videosSlice} from "./slices/videos/videos-slice"; 7 | import {logRecordsSlice} from "./slices/log-records/log-records-slice"; 8 | 9 | export const store = configureStore({ 10 | reducer: { 11 | cameras: cameraSlice.reducer, 12 | recording: recordingSlice.reducer, 13 | framerate: framerateSlice.reducer, 14 | logs: logRecordsSlice.reducer, 15 | theme: themeSlice.reducer, 16 | videos: videosSlice.reducer, 17 | } 18 | }); 19 | 20 | 21 | -------------------------------------------------------------------------------- /skellycam/utilities/find_available_port.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import socket 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | def find_available_port(start_port: int) -> int: 8 | logger.debug(f"Finding available port starting at {start_port}") 9 | port = start_port 10 | while True: 11 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 12 | logger.debug(f"Trying port {port}...") 13 | try: 14 | s.bind(("localhost", port)) 15 | logger.debug(f"Port {port} is available!") 16 | return port 17 | except socket.error as e: 18 | logger.debug(f"Port {port} is not available: `{e}`") 19 | port += 1 20 | if port > 65535: # No more ports available 21 | logger.error("No ports available!") 22 | raise 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use a base image with Python and PowerShell 2 | FROM mcr.microsoft.com/windows/servercore:ltsc2022 3 | 4 | # Install Chocolatey and Rcedit 5 | RUN powershell -Command "Set-ExecutionPolicy Bypass -Scope Process -Force; \ 6 | iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))" 7 | RUN choco install rcedit -y 8 | 9 | # Install Python and Rust 10 | RUN choco install python --version=3.11 -y 11 | RUN choco install rust -y 12 | 13 | # Set the working directory 14 | WORKDIR /app 15 | 16 | # Copy the PowerShell script and your project files into the container 17 | chanCOPY installers/install-windows-pyapp.ps1 /app/ 18 | COPY installers /app/ 19 | 20 | # Run your PowerShell script 21 | RUN powershell -ExecutionPolicy Bypass -File install-windows-pyapp.ps1 22 | 23 | # Entry point to run the built executable 24 | ENTRYPOINT ["./skellycam-server.exe"] 25 | -------------------------------------------------------------------------------- /skellycam/system/logging_configuration/package_log_quieters.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | def suppress_noisy_package_logs(): 4 | # Suppress some external loggers that are too verbose for our context/taste 5 | logging.getLogger("tzlocal").setLevel(logging.WARNING) 6 | logging.getLogger("matplotlib").setLevel(logging.WARNING) 7 | logging.getLogger("httpx").setLevel(logging.WARNING) 8 | logging.getLogger("asyncio").setLevel(logging.WARNING) 9 | logging.getLogger("websockets").setLevel(logging.INFO) 10 | logging.getLogger("websocket").setLevel(logging.INFO) 11 | logging.getLogger("watchfiles").setLevel(logging.WARNING) 12 | logging.getLogger("httpcore").setLevel(logging.WARNING) 13 | logging.getLogger("urllib3").setLevel(logging.WARNING) 14 | logging.getLogger("comtypes").setLevel(logging.WARNING) 15 | logging.getLogger("uvicorn").setLevel(logging.WARNING) 16 | 17 | -------------------------------------------------------------------------------- /skellycam-ui/electron/main/services/logger.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from 'electron'; 2 | import { exec } from 'child_process'; 3 | 4 | export class LifecycleLogger { 5 | static logProcessInfo() { 6 | console.log(` 7 | ============================================ 8 | Starting SkellyCam v${app.getVersion()} 9 | Platform: ${process.platform}-${process.arch} 10 | Node: ${process.versions.node} 11 | Chrome: ${process.versions.chrome} 12 | Electron: ${process.versions.electron} 13 | ============================================`); 14 | } 15 | 16 | static logWindowCreation(win: BrowserWindow) { 17 | console.log(`[Window Manager] Created window ID: ${win.id}`); 18 | } 19 | 20 | static logPythonProcess(pythonProcess: ReturnType) { 21 | console.log(`[Python Server] Started process PID: ${pythonProcess.pid}`); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /skellycam-ui/old_electron/main/helpers/logger.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from 'electron'; 2 | import { exec } from 'child_process'; 3 | 4 | export class LifecycleLogger { 5 | static logProcessInfo() { 6 | console.log(` 7 | ============================================ 8 | Starting SkellyCam v${app.getVersion()} 9 | Platform: ${process.platform}-${process.arch} 10 | Node: ${process.versions.node} 11 | Chrome: ${process.versions.chrome} 12 | Electron: ${process.versions.electron} 13 | ============================================`); 14 | } 15 | 16 | static logWindowCreation(win: BrowserWindow) { 17 | console.log(`[Window Manager] Created window ID: ${win.id}`); 18 | } 19 | 20 | static logPythonProcess(pythonProcess: ReturnType) { 21 | console.log(`[Python Server] Started process PID: ${pythonProcess.pid}`); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /skellycam-ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "DOM", 7 | "DOM.Iterable", 8 | "ESNext" 9 | ], 10 | "allowJs": false, 11 | "skipLibCheck": true, 12 | "esModuleInterop": false, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "ESNext", 17 | "moduleResolution": "Node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "baseUrl": "./", 23 | "paths": { 24 | "@/*": [ 25 | "src/*" 26 | ] 27 | } 28 | }, 29 | "include": [ 30 | "src", 31 | "old_electron", 32 | "src/lib/**/*" 33 | ], 34 | "references": [ 35 | { 36 | "path": "./tsconfig.node.json" 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/ui-components/Footer.tsx: -------------------------------------------------------------------------------- 1 | // Update Copyright.tsx 2 | import Typography from "@mui/material/Typography"; 3 | import Link from "@mui/material/Link"; 4 | import * as React from "react"; 5 | import {useTheme} from "@mui/material"; 6 | 7 | export const Footer = function () { 8 | const theme = useTheme(); 9 | 10 | return ( 11 | 16 | {'w/ '} 17 | 18 | ❤️ 19 | {' from the '} 20 | 21 | FreeMoCap Foundation 22 | {' '} 23 | {new Date().getFullYear()} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /skellycam/utilities/kill_process_on_port.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import psutil 4 | 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def kill_process_on_port(port: int): 10 | for proc in psutil.process_iter(['pid', 'name']): 11 | if proc.info['pid'] == 0: 12 | # Skip kernel processes 13 | continue 14 | try: 15 | for conn in proc.connections(kind='inet'): 16 | if conn.laddr.port == port: 17 | logger.warning( 18 | f"Process already running on port: {port} (PID:{proc.info['pid']}), shutting it down...[TODO - HANDLE THIS BETTER! Figure out why we're leaving behind zombie processes...]") 19 | proc.kill() 20 | except psutil.AccessDenied or psutil.NoSuchProcess: 21 | continue 22 | 23 | 24 | if __name__ == "__main__": 25 | from skellycam.api.server_constants import PORT 26 | kill_process_on_port(PORT) 27 | -------------------------------------------------------------------------------- /skellycam-ui/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | width: 100%; 3 | height: 100vh; 4 | overflow: hidden; 5 | } 6 | 7 | .logo-box { 8 | position: relative; 9 | height: 9em; 10 | } 11 | 12 | .logo { 13 | position: absolute; 14 | left: calc(50% - 4.5em); 15 | height: 6em; 16 | padding: 1.5em; 17 | will-change: filter; 18 | transition: filter 300ms; 19 | } 20 | 21 | .logo:hover { 22 | filter: drop-shadow(0 0 2em #646cffaa); 23 | } 24 | 25 | @keyframes logo-spin { 26 | from { 27 | transform: rotate(0deg); 28 | } 29 | to { 30 | transform: rotate(360deg); 31 | } 32 | } 33 | 34 | @media (prefers-reduced-motion: no-preference) { 35 | .logo.electron { 36 | animation: logo-spin infinite 20s linear; 37 | } 38 | } 39 | 40 | .card { 41 | padding: 2em; 42 | } 43 | 44 | .read-the-docs { 45 | color: #888; 46 | } 47 | 48 | .flex-center { 49 | display: flex; 50 | align-items: center; 51 | justify-content: center; 52 | } 53 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/log-records/logs-types.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const LogRecordSchema = z.object({ 4 | name: z.string(), 5 | msg: z.string().nullable().default(""), 6 | args: z.array(z.any()), 7 | levelname: z.string(), 8 | levelno: z.number(), 9 | pathname: z.string(), 10 | filename: z.string(), 11 | module: z.string(), 12 | exc_info: z.string().nullable(), 13 | exc_text: z.string().nullable(), 14 | stack_info: z.string().nullable(), 15 | lineno: z.number(), 16 | funcName: z.string(), 17 | created: z.number(), 18 | msecs: z.number(), 19 | relativeCreated: z.number(), 20 | thread: z.number(), 21 | threadName: z.string(), 22 | processName: z.string(), 23 | process: z.number(), 24 | delta_t: z.string(), 25 | message: z.string(), 26 | asctime: z.string(), 27 | formatted_message: z.string(), 28 | type: z.string(), 29 | }); 30 | 31 | export type LogRecord = z.infer; 32 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/thunks/close-cameras-thunks.ts: -------------------------------------------------------------------------------- 1 | import {createAsyncThunk} from '@reduxjs/toolkit'; 2 | import {useAppUrls} from "@/hooks/useAppUrls"; 3 | 4 | 5 | 6 | export const closeCameras = createAsyncThunk( 7 | 'cameras/close', 8 | async () => { 9 | console.log(`Closing cameras...`); 10 | try { 11 | 12 | const closeCamerasURL = useAppUrls.getHttpEndpointUrls().closeAll 13 | 14 | 15 | console.log(`Sending close request to ${closeCamerasURL}`); 16 | const response = await fetch(closeCamerasURL, { 17 | method: 'DELETE', 18 | }); 19 | 20 | if (response.ok) { 21 | console.log('Cameras closed successfully'); 22 | } else { 23 | throw new Error(`Failed to close cameras: ${response.statusText}`); 24 | } 25 | } catch (error) { 26 | console.error('Recording start failed:', error); 27 | throw error; 28 | } 29 | } 30 | ); 31 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/videos/videos-selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit'; 2 | import { RootState } from '../../types'; 3 | 4 | export const selectVideoFolder = (state: RootState) => state.videos.folder; 5 | export const selectVideoFiles = (state: RootState) => state.videos.files; 6 | export const selectSelectedVideo = (state: RootState) => state.videos.selectedFile; 7 | export const selectVideoPlaybackState = (state: RootState) => state.videos.playbackState; 8 | export const selectVideoLoadingState = (state: RootState) => state.videos.isLoading; 9 | export const selectVideoError = (state: RootState) => state.videos.error; 10 | 11 | export const selectVideosByExtension = createSelector( 12 | [selectVideoFiles, (_: RootState, extension: string) => extension], 13 | (files, extension) => 14 | files.filter((file: { name: string; }) => file.name.toLowerCase().endsWith(extension)) 15 | ); 16 | 17 | export const selectIsVideoPlaying = createSelector( 18 | [selectVideoPlaybackState], 19 | (playback) => playback.isPlaying 20 | ); 21 | -------------------------------------------------------------------------------- /skellycam-ui/src/layout/AppContent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {ThemeProvider} from '@mui/material/styles'; 3 | import {HashRouter} from 'react-router-dom'; 4 | import {CssBaseline} from "@mui/material"; 5 | import {BasePanelLayout} from "@/layout/BasePanelLayout"; 6 | import {createExtendedTheme} from "@/layout/paperbase-theme"; 7 | import {useAppSelector} from "@/store"; 8 | import {BaseContentRouter} from "@/layout/BaseContentRouter"; 9 | 10 | export const AppContent = function () { 11 | 12 | const themeMode = useAppSelector(state => state.theme.mode); 13 | // Create theme dynamically based on current mode 14 | 15 | const theme = React.useMemo(() => 16 | createExtendedTheme(themeMode), 17 | [themeMode] 18 | ); 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /skellycam/utilities/check_main_processs_heartbeat.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import time 3 | 4 | import logging 5 | from typing import Final 6 | 7 | logger = logging.getLogger(__name__) 8 | HEARTBEAT_INTERVAL_SECONDS: Final[float] = 1.0 9 | HEARTBEAT_TIMEOUT_SECONDS: Final[float] = 300.0 10 | def check_main_process_heartbeat(*, 11 | heartbeat_timestamp: multiprocessing.Value, 12 | global_kill_flag: multiprocessing.Value) -> bool: 13 | """Check if main process is still alive based on heartbeat.""" 14 | current_time: float = time.perf_counter() 15 | last_heartbeat: float = heartbeat_timestamp.value 16 | 17 | time_since_heartbeat: float = current_time - last_heartbeat 18 | 19 | if time_since_heartbeat > HEARTBEAT_TIMEOUT_SECONDS: 20 | logger.error( 21 | f"Main process heartbeat timeout! " 22 | f"Last heartbeat was {time_since_heartbeat:.1f}s ago " 23 | f"(timeout: {HEARTBEAT_TIMEOUT_SECONDS}s)" 24 | ) 25 | global_kill_flag.value = True 26 | return False 27 | 28 | return True -------------------------------------------------------------------------------- /skellycam/core/camera/opencv/opencv_helpers/create_initial_frame_recarray.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from skellycam.core.camera.config.camera_config import CameraConfig 4 | from skellycam.core.camera_group.camera_group_ipc import CameraGroupIPC 5 | from skellycam.core.types.numpy_record_dtypes import create_frame_dtype 6 | 7 | 8 | def create_initial_frame_rec_array(config: CameraConfig, ipc: CameraGroupIPC) -> np.recarray: 9 | # Create initial frame record array 10 | frame_dtype = create_frame_dtype(config) 11 | frame_rec_array = np.recarray(1, dtype=frame_dtype) 12 | # Initialize the frame metadata 13 | 14 | frame_rec_array.frame_metadata.camera_config[0] = config.to_numpy_record_array() 15 | frame_rec_array.frame_metadata.frame_number[0] = -1 16 | frame_rec_array.frame_metadata.timebase_mapping[0] = ipc.timebase_mapping.to_numpy_record_array() 17 | # Initialize the image with zeros 18 | image_shape = (config.resolution.height, config.resolution.width, config.color_channels) 19 | frame_rec_array.image[0] = np.zeros(image_shape, dtype=np.uint8) + config.camera_index 20 | return frame_rec_array 21 | -------------------------------------------------------------------------------- /skellycam-ui/electron/main/ipc.ts: -------------------------------------------------------------------------------- 1 | // electron/main/ipc.ts 2 | import { ipcMain } from 'electron'; 3 | import { api } from './api'; 4 | import superjson from 'superjson'; 5 | 6 | export function setupIPC(): void { 7 | ipcMain.handle('trpc', async (_event, { path, input }) => { 8 | try { 9 | const caller = api.createCaller({}); 10 | 11 | const pathParts = path.split('.'); 12 | let current: any = caller; 13 | 14 | for (let i = 0; i < pathParts.length - 1; i++) { 15 | current = current[pathParts[i]]; 16 | if (!current) { 17 | throw new Error(`Router not found: ${pathParts.slice(0, i + 1).join('.')}`); 18 | } 19 | } 20 | 21 | const procedureName = pathParts[pathParts.length - 1]; 22 | const fn = current[procedureName]; 23 | 24 | if (typeof fn !== 'function') { 25 | throw new Error(`Procedure not found: ${path}`); 26 | } 27 | 28 | const result = await fn(input); 29 | return superjson.serialize(result); 30 | } catch (error) { 31 | console.error(`IPC Error for ${path}:`, error); 32 | throw error; 33 | } 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /skellycam-ui/src/layout/paperbase_theme/PaperbaseContent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {ThemeProvider} from '@mui/material/styles'; 3 | import {HashRouter} from 'react-router-dom'; 4 | import {CssBaseline} from "@mui/material"; 5 | import {BasePanelLayout} from "@/layout/BasePanelLayout"; 6 | import {createExtendedTheme} from "@/layout/paperbase_theme/paperbase-theme"; 7 | import {useAppSelector} from "@/store/AppStateStore"; 8 | import {BaseContentRouter} from "@/layout/BaseContentRouter"; 9 | 10 | export const PaperbaseContent = function () { 11 | 12 | const themeMode = useAppSelector(state => state.theme.mode); 13 | // Create theme dynamically based on current mode 14 | 15 | const theme = React.useMemo(() => 16 | createExtendedTheme(themeMode), 17 | [themeMode] 18 | ); 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/ui-components/Header.tsx: -------------------------------------------------------------------------------- 1 | // Update Header.tsx 2 | import * as React from 'react'; 3 | import AppBar from '@mui/material/AppBar'; 4 | import Toolbar from '@mui/material/Toolbar'; 5 | import Typography from '@mui/material/Typography'; 6 | import {Box, useTheme} from '@mui/material'; 7 | import ThemeToggle from './ThemeToggle'; 8 | 9 | export const Header = function () { 10 | const theme = useTheme(); 11 | 12 | return ( 13 | 22 | 23 | 24 | SkellyCam 💀📸 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | 34 | export default Header; 35 | -------------------------------------------------------------------------------- /skellycam/api/http/ui/ui_router.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from pathlib import Path 4 | 5 | from fastapi import APIRouter 6 | from skellycam.api.server import PORT 7 | from starlette.responses import HTMLResponse 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | ui_router = APIRouter(tags=['ui'], prefix='/ui') 12 | 13 | 14 | @ui_router.get("/skellycam.__package_name__", response_class=HTMLResponse) 15 | def serve_ui(): 16 | logger.info("Serving UI HTML to `/ui`") 17 | file_path = os.path.join(os.path.dirname(__file__), 'ui.html') 18 | with open(file_path, 'r', encoding='utf-8') as file: 19 | ui_html_string = file.read() 20 | # ui_html_string = ui_html_string.replace("{{HTTP_URL}}", APP_URL) 21 | # ui_html_string = ui_html_string.replace("{{WEBSOCKET_URL}}", APP_URL.replace("http", "ws")) 22 | ui_html_string = ui_html_string.replace("{{SKELLYCAM_DATA_FOLDER}}", str(Path().home()/"skellycam_data")).replace("\\", "/") 23 | return HTMLResponse(content=ui_html_string, status_code=200) 24 | 25 | 26 | if __name__ == "__main__": 27 | import uvicorn 28 | 29 | uvicorn.run(ui_router, host="localhost", port=PORT) 30 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/camera-config-tree-view/NoCamerasPlaceholder.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Typography } from "@mui/material"; 3 | import { TreeItem } from "@mui/x-tree-view/TreeItem"; 4 | 5 | export const NoCamerasPlaceholder: React.FC = () => { 6 | return ( 7 | 17 | 18 | No cameras detected 19 | 20 | 25 | Click refresh to scan for available cameras 26 | 27 | 28 | } 29 | /> 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /skellycam/utilities/check_ffmpeg_and_h264_codec.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | 4 | def check_ffmpeg(): 5 | try: 6 | output = subprocess.check_output(['ffmpeg', '-version'], stderr=subprocess.STDOUT) 7 | print("FFmpeg is installed.") 8 | except subprocess.CalledProcessError as e: 9 | print("FFmpeg is not installed or not accessible.") 10 | return False 11 | return True 12 | 13 | 14 | def check_h264_support(): 15 | try: 16 | output = subprocess.check_output(['ffmpeg', '-codecs'], stderr=subprocess.STDOUT) 17 | if b'libx264' in output: 18 | print("H.264 codec (libx264) is available.") 19 | return True 20 | else: 21 | print("H.264 codec (libx264) is not available.") 22 | return False 23 | except subprocess.CalledProcessError as e: 24 | print("Error checking FFmpeg codecs:", e.output.decode()) 25 | return False 26 | 27 | 28 | if __name__ == "__main__": 29 | if check_ffmpeg() and check_h264_support(): 30 | print("System is properly configured for H.264 encoding.") 31 | else: 32 | print("System is not properly configured for H.264 encoding.") 33 | -------------------------------------------------------------------------------- /skellycam-ui/electron-builder.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": "skellycam.app", 3 | "directories": { 4 | "output": "release/${version}" 5 | }, 6 | "files": [ 7 | "dist-electron", 8 | "dist", 9 | "skellycam_server.exe", 10 | "../shared/skellycam-logo/skellycam-favicon.ico", 11 | "../shared/skellycam-logo/skellycam-logo.png" 12 | ], 13 | "icon": "../shared/skellycam-logo/skellycam-favicon.ico", 14 | "asar": true, 15 | "asarUnpack": [ 16 | "**/skellycam_server.exe", 17 | "**/skellycam-logo.png" 18 | ], 19 | "mac": { 20 | "artifactName": "${productName}_${version}_installer.${ext}", 21 | "target": [ 22 | "dmg", 23 | "zip" 24 | ] 25 | }, 26 | "win": { 27 | "target": [ 28 | { 29 | "target": "nsis", 30 | "arch": [ 31 | "x64" 32 | ] 33 | } 34 | ], 35 | "artifactName": "${productName}_${version}_installer.${ext}" 36 | }, 37 | "nsis": { 38 | "oneClick": false, 39 | "perMachine": false, 40 | "allowToChangeInstallationDirectory": true, 41 | "deleteAppDataOnUninstall": false 42 | }, 43 | "publish": { 44 | "provider": "generic", 45 | "channel": "latest", 46 | "url": "https://github.com/freemocap/freemocap" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/update/README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # electron-auto-update 2 | 3 | [English](README.md) | 简体中文 4 | 5 | 使用`electron-updater`实现electron程序的更新检测、下载和安装等功能。 6 | 7 | ```sh 8 | npm i electron-updater 9 | ``` 10 | 11 | ### 主要逻辑 12 | 13 | 1. ##### 更新地址、更新信息脚本的配置 14 | 15 | 在`electron-builder.json5`添加`publish`字段,用来配置更新地址和使用哪种策略作为更新服务 16 | 17 | ``` json5 18 | { 19 | "publish": { 20 | "provider": "generic", // 提供者、提供商 21 | "channel": "latest", // 生成yml文件的名称 22 | "url": "https://foo.com/" //更新地址 23 | } 24 | } 25 | ``` 26 | 27 | 更多见 : [electron-builder.json5...](xxx) 28 | 29 | 2. ##### Electron更新逻辑 30 | 31 | - 检测更新是否可用; 32 | 33 | - 检测服务端的软件版本; 34 | 35 | - 检测更新是否可用; 36 | 37 | - 下载服务端新版软件(当更新可用); 38 | - 安装方式; 39 | 40 | 更多见 : [update...](https://github.com/electron-vite/electron-vite-react/blob/main/electron/main/update.ts) 41 | 42 | 3. ##### Electron更新UI页面 43 | 44 | 主要功能是:用户触发上述(2.)更新逻辑的UI页面。用户可以通过点击页面触发electron更新的不同功能。 45 | 更多见 : [components/update.ts...](https://github.com/electron-vite/electron-vite-react/tree/main/src/components/update/index.tsx) 46 | 47 | --- 48 | 49 | 这里建议更新触发以用户操作触发(本项目的以用户点击 **更新检测** 后触发electron更新功能) 50 | 51 | 关于更多使用`electron-updater`进行electron更新,见文档:[auto-update](https://www.electron.build/.html) 52 | -------------------------------------------------------------------------------- /skellycam/core/camera_group/timestamps/numpy_timestamps/save_timestamps_statistics_summary.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import numpy as np 4 | 5 | from skellycam.core.camera_group.timestamps.recording_timestamp_stats import RecordingTimestampsStats 6 | from skellycam.core.recorders.videos.recording_info import RecordingInfo 7 | 8 | logger = logging.getLogger(__name__) 9 | def save_timestamp_statistics_summary( 10 | multiframe_rows_recarray: np.recarray, 11 | recording_info: RecordingInfo, 12 | number_of_cameras: int,) -> RecordingTimestampsStats: 13 | stats = RecordingTimestampsStats.from_multiframe_rows( 14 | multiframe_rows=multiframe_rows_recarray, 15 | recording_info=recording_info, 16 | number_of_cameras=number_of_cameras, 17 | ) 18 | 19 | stats_json_path = recording_info.timestamp_stats_json_file_path 20 | 21 | with open(stats_json_path, 'w', encoding='utf-8') as f: 22 | f.write(stats.to_json()) 23 | # logger.debug(f"Saved timestamp statistics to {stats_json_path}") 24 | 25 | stats_text_path = recording_info.timestamp_stats_text_file_path 26 | with open(stats_text_path, 'w', encoding='utf-8') as f: 27 | f.write(str(stats)) 28 | logger.debug(f"Saved timestamp statistics summary to {stats_text_path}") 29 | return stats 30 | 31 | -------------------------------------------------------------------------------- /skellycam/utilities/wait_functions.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | 4 | 5 | def wait_1s(): 6 | time.sleep(1.0) 7 | 8 | 9 | def wait_100ms(): 10 | time.sleep(0.1) 11 | 12 | 13 | def wait_10ms(): 14 | time.sleep(1e-2) 15 | 16 | def wait_30ms(): 17 | """ 18 | Once per frame-ish 19 | """ 20 | wait_10ms() 21 | wait_10ms() 22 | wait_10ms() 23 | 24 | 25 | def wait_1ms(): 26 | time.sleep(1e-3) 27 | 28 | def wait_100us(): 29 | time.sleep(1e-4) 30 | 31 | def wait_10us(): 32 | time.sleep(1e-5) 33 | 34 | def wait_1us(): 35 | time.sleep(1e-6) 36 | 37 | 38 | 39 | async def await_1s(): 40 | await asyncio.sleep(1.0) 41 | 42 | 43 | async def await_100ms(): 44 | await asyncio.sleep(1e-1) 45 | 46 | async def await_10ms(): 47 | await asyncio.sleep(1e-2) 48 | 49 | 50 | async def await_1ms(): 51 | await asyncio.sleep(1e-3) 52 | 53 | 54 | 55 | if __name__ == "__main__": 56 | print("Testing wait functions") 57 | 58 | tic = time.perf_counter_ns() 59 | for i in range(1000): 60 | wait_1ms() 61 | toc = time.perf_counter_ns() 62 | print(f"WAITED 1ms {1000} times in {(toc - tic)/1e9} s") 63 | 64 | tic = time.perf_counter_ns() 65 | for i in range(1000): 66 | wait_10ms() 67 | toc = time.perf_counter_ns() 68 | print(f"WAITED 10ms {1000} times in {(toc - tic)/1e9} s") 69 | 70 | 71 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/ui-components/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IconButton, Tooltip, useTheme } from '@mui/material'; 3 | import Brightness4Icon from '@mui/icons-material/Brightness4'; // Dark mode icon 4 | import Brightness7Icon from '@mui/icons-material/Brightness7'; // Light mode icon 5 | import { useAppDispatch, useAppSelector } from '@/store'; 6 | import { themeModeToggled } from '@/store/slices/theme'; 7 | 8 | export const ThemeToggle: React.FC = () => { 9 | const dispatch = useAppDispatch(); 10 | const theme = useTheme(); 11 | const themeMode = useAppSelector(state => state.theme.mode); 12 | const isDarkMode = themeMode === 'dark'; 13 | 14 | return ( 15 | 16 | dispatch(themeModeToggled())} 18 | color="inherit" 19 | aria-label="toggle theme" 20 | edge="end" 21 | sx={{ 22 | '&:hover': { 23 | backgroundColor: theme.palette.action.hover, 24 | }, 25 | }} 26 | > 27 | {isDarkMode ? : } 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default ThemeToggle; 34 | -------------------------------------------------------------------------------- /skellycam/core/types/type_overloads.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import multiprocessing 3 | import threading 4 | 5 | import numpy as np 6 | from pydantic import SkipValidation 7 | 8 | CameraIdString = str 9 | CameraGroupIdString = str 10 | CameraNameString = str 11 | SharedMemoryName = str 12 | CameraIndexInt = int 13 | CameraBackendInt = int # OpenCV backend ID, e.g., cv2.CAP_ANY, cv2.CAP_MSMF, cv2.CAP_DSHOW, etc. 14 | CameraBackendNameString = str # Name of the backend, e.g., "MSMF", "DShow", etc. 15 | CameraVendorIdInt = int # Vendor ID of the camera, e.g., 0x046D for Logitech 16 | CameraProductIdInt = int # Product ID of the camera, e.g., 0x0825 for Logitech C920 17 | CameraDevicePathString = str # Path to the camera device, e.g., "/dev/video0" on Linux or "COM3"/ on Windows 18 | FrameNumberInt = int 19 | MultiframeTimestampFloat = float|np.floating # Mean timestamp of the frames in a multiframe (converted to milliseconds) 20 | Base64JPEGImage = str # Base64 encoded JPEG image 21 | RecordingManagerIdString = str 22 | TopicSubscriptionQueue = SkipValidation[multiprocessing.Queue] 23 | TopicPublicationQueue = SkipValidation[multiprocessing.Queue] 24 | 25 | WorkerType = SkipValidation[threading.Thread | multiprocessing.Process] 26 | 27 | IMAGE_DATA_DTYPE = np.uint8 28 | BYTES_PER_MONO_PIXEL = np.dtype(IMAGE_DATA_DTYPE).itemsize 29 | 30 | class WorkerStrategy(enum.Enum): 31 | THREAD = threading.Thread 32 | PROCESS = multiprocessing.Process 33 | 34 | -------------------------------------------------------------------------------- /skellycam/system/logging_configuration/formatters/color_formatter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from copy import deepcopy 3 | 4 | from .custom_formatter import CustomFormatter 5 | from ..log_format_string import LOG_POINTER_STRING 6 | from ..logging_color_helpers import get_hashed_color 7 | 8 | LOG_COLOR_CODES = { 9 | "LOOP": "\033[90m", # Grey 10 | "TRACE": "\033[37m", # White 11 | "DEBUG": "\033[34m", # Blue 12 | "INFO": "\033[96m", # Cyan 13 | "SUCCESS": "\033[95m", # Magenta 14 | "API": "\033[92m", # Green 15 | "WARNING": "\033[33m", # Yellow 16 | "ERROR": "\033[41m", # Red background 17 | } 18 | class ColorFormatter(CustomFormatter): 19 | """Adds ANSI colors to PID, TID, and log messages""" 20 | 21 | def format(self, record: logging.LogRecord) -> str: 22 | # Apply PID/TID colors 23 | record = deepcopy(record) 24 | record.pid_color = get_hashed_color(record.process) 25 | record.tid_color = get_hashed_color(record.thread) 26 | 27 | # Apply level color to message 28 | level_color = LOG_COLOR_CODES.get(record.levelname, "") 29 | record.msg = f"{level_color}{record.msg}\033[0m" 30 | 31 | # Format with parent class 32 | formatted = super().format(record) 33 | formatted = f"{level_color}{formatted}\033[0m" 34 | # Add color to structural elements 35 | return formatted.replace(LOG_POINTER_STRING, f"{level_color}{LOG_POINTER_STRING}\033[0m") -------------------------------------------------------------------------------- /installers/nuitka_scripts/nuitka_installer_notes.md: -------------------------------------------------------------------------------- 1 | # Nuitka Installer Commands 2 | 3 | Copy-paste these commands into your terminal to build the SkellyCam server with Nuitka. 4 | 5 | > run `nuitka --help` for more information on the options used here. 6 | 7 | run from /skellycam-ui folder: 8 | 9 | ## Windows 10 | 11 | ``` 12 | nuitka --onefile --windows-icon-from-ico=../shared/skellycam-logo/skellycam-favicon.ico --user-package-configuration-file=../installers/nuitka_scripts/skellycam-nuitka.config.yml --remove-output --output-filename=skellycam_server.exe ../skellycam/__main__.py 13 | ``` 14 | 15 | ## Mac 16 | ``` 17 | nuitka --onefile --macos-create-app-bundle=1 --macos-app-icon=shared/skellycam-logo/skellycam-favicon.ico --user-package-configuration-file=installers/skellycam-nuitka.config.yml --output-filename=skellycam-ui/skellycam_server.exe skellycam/__main__.py 18 | ``` 19 | ## Linux 20 | ``` 21 | nuitka --onefile --linux-icon=shared/skellycam-logo/skellycam-favicon.ico --user-package-configuration-file=installers/skellycam-nuitka.config.yml skellycam/__main__.py 22 | ``` 23 | ___ 24 | 25 | ## DEBUG OPTIONS 26 | 27 | # --report=compilation-report.xml 28 | 29 | ## not working yet 30 | 31 | ### --onefile-windows-splash-screen-image=shared/skellycam-logo/skellycam-logo.png 32 | 33 | - makes a giant splash screen that covers the whole screen and never goes away lol 34 | - The docs have instructions on how to turn off the splash screen when the app starts up 35 | -------------------------------------------------------------------------------- /skellycam/api/http/app/shutdown.py: -------------------------------------------------------------------------------- 1 | """ 2 | Clean shutdown endpoint with proper async handling. 3 | """ 4 | import logging 5 | import os 6 | import signal 7 | 8 | from fastapi import APIRouter, Request 9 | from fastapi.responses import JSONResponse 10 | 11 | from skellycam.utilities.wait_functions import await_100ms 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | shutdown_router = APIRouter(tags=["App"]) 16 | 17 | 18 | @shutdown_router.get( 19 | "/shutdown", 20 | summary="Gracefully shutdown the server", 21 | response_model=dict[str, str] 22 | ) 23 | async def shutdown_server( 24 | request: Request, 25 | ) -> JSONResponse: 26 | """ 27 | Initiate graceful server shutdown. 28 | 29 | This endpoint triggers a graceful shutdown of the entire SkellyCam system, 30 | including all camera groups and the server itself. 31 | 32 | Returns: 33 | JSON response confirming shutdown initiation 34 | """ 35 | logger.api(f"Shutdown requested via API - {request.url}") 36 | 37 | # Send SIGTERM to ourselves - this triggers the existing shutdown flow 38 | request.app.state.global_kill_flag.value = True 39 | await await_100ms() 40 | os.kill(os.getpid(), signal.SIGTERM) 41 | logger.info("Sent SIGTERM signal to initiate shutdown") 42 | return JSONResponse( 43 | content={ 44 | "status": "shutdown_initiated", 45 | "message": "Server shutting down. Goodbye! 👋" 46 | }, 47 | status_code=200 48 | ) 49 | -------------------------------------------------------------------------------- /skellycam/core/camera/opencv/opencv_helpers/determine_backend.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from platform import platform 3 | 4 | import cv2 5 | from cv2.videoio_registry import getBackendName 6 | from cv2_enumerate_cameras import supported_backends 7 | from pydantic import BaseModel 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class OpenCVBackend(BaseModel): 13 | id: int 14 | name: str 15 | 16 | @classmethod 17 | def from_backend_id(cls, backend_id: int) -> 'OpenCVBackend': 18 | name = getBackendName(backend_id) 19 | if name is None: 20 | logger.warning(f"Unknown OpenCV backend ID: {backend_id}. Defaulting to cv2.CAP_ANY.") 21 | backend_id = cv2.CAP_ANY 22 | name = getBackendName(backend_id) 23 | return cls(id=backend_id, name=name) 24 | 25 | 26 | def determine_opencv_camera_backend() -> OpenCVBackend: 27 | if "windows" in platform().lower(): 28 | # TODO - Try MSMF? We've used CAP_DSHOW for a long time, but MSMF is the default on Windows 10+ so may be worth trying. 29 | backend = OpenCVBackend.from_backend_id(cv2.CAP_DSHOW) 30 | else: 31 | backend = OpenCVBackend.from_backend_id(supported_backends[0] if len(supported_backends) > 0 else cv2.CAP_ANY) 32 | logger.debug(f"Determined OpenCV backend: {backend.name} (ID: {backend.id})") 33 | return backend 34 | 35 | 36 | if __name__ == "__main__": 37 | b = determine_opencv_camera_backend() 38 | print(f"OpenCV Backend: {b.name} (ID: {b.id})") 39 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/camera-config-tree-view/CameraConfigTreeSection.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TreeItem } from "@mui/x-tree-view/TreeItem"; 3 | import { useAppDispatch } from "@/store"; 4 | import { cameraDesiredConfigUpdated } from "@/store/slices/cameras/cameras-slice"; 5 | import { Camera, CameraConfig } from "@/store/slices/cameras/cameras-types"; 6 | import {CameraConfigPanel} from "@/components/camera-config-panel/CameraConfigPanel"; 7 | 8 | interface CameraConfigTreeSectionProps { 9 | camera: Camera; 10 | } 11 | 12 | export const CameraConfigTreeSection: React.FC = ({ 13 | camera, 14 | }) => { 15 | const dispatch = useAppDispatch(); 16 | 17 | const handleConfigChange = (newConfig: CameraConfig): void => { 18 | dispatch( 19 | cameraDesiredConfigUpdated({ 20 | cameraId: camera.id, 21 | config: newConfig, 22 | }) 23 | ); 24 | }; 25 | 26 | return ( 27 | 35 | } 36 | /> 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /skellycam/system/logging_configuration/log_test_messages.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def log_test_messages(logger: logging.Logger): 5 | logger.loop("This is a LOOP message, value `4` -> For logs that are printed in a loop") 6 | logger.trace("This is a TRACE message, value `5` -> Low level logs for deep debugging") 7 | logger.debug("This is a DEBUG message, value `10` -> Detailed information for devs and curious folk") 8 | logger.info("This is an INFO message, value `20` -> General information about the program") 9 | logger.success("This is a SUCCESS message, value `22` -> OMG, something worked!") 10 | logger.api("This is an IMPORTANT message, value `25` -> API calls/responses") 11 | logger.warning( 12 | "This is a WARNING message, value `30` -> Something unexpected happened, but it's necessarily an error" 13 | ) 14 | logger.error("This is an ERROR message, value `40` -> Something went wrong!") 15 | 16 | print("----------This is a regular ol' print message.------------------") 17 | 18 | import time 19 | 20 | iters = 10 21 | for iter in range(1, iters + 1): 22 | wait_time = iter / 10 23 | logger.loop("Starting timers loop (Δt should probably be near 0, unless you've got other stuff going on)") 24 | tic = time.perf_counter_ns() 25 | time.sleep(wait_time) 26 | toc = time.perf_counter_ns() 27 | elapsed_time = (toc - tic) / 1e9 28 | logger.trace(f"Done {wait_time} sec timer - elapsed time:{elapsed_time}s (Δt should be ~{wait_time}s)") 29 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/framerate/framerate-types.ts: -------------------------------------------------------------------------------- 1 | // framerate-types.ts 2 | import { z } from 'zod'; 3 | 4 | // The actual framerate data structure used in the Redux store 5 | export const FramerateDataSchema = z.object({ 6 | mean: z.number(), 7 | std: z.number(), 8 | current: z.number(), 9 | }); 10 | 11 | export type FramerateData = z.infer; 12 | 13 | // If you have a different schema from the backend with more detailed stats, 14 | // you can keep it separate for parsing/validation but transform it to FramerateData 15 | export const DetailedFramerateSchema = z.object({ 16 | mean_frame_duration_ms: z.number(), 17 | mean_frames_per_second: z.number(), 18 | frame_duration_max: z.number(), 19 | frame_duration_min: z.number(), 20 | frame_duration_mean: z.number(), 21 | frame_duration_stddev: z.number(), 22 | frame_duration_median: z.number(), 23 | frame_duration_coefficient_of_variation: z.number(), 24 | calculation_window_size: z.number(), 25 | framerate_source: z.string(), 26 | }); 27 | 28 | export type DetailedFramerate = z.infer; 29 | 30 | // Helper function to convert detailed framerate to simple framerate data 31 | export function detailedToSimpleFramerate(detailed: DetailedFramerate): FramerateData { 32 | return { 33 | mean: detailed.mean_frames_per_second, 34 | std: detailed.frame_duration_stddev, 35 | current: detailed.mean_frames_per_second, // or calculate from mean_frame_duration_ms 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /skellycam/core/recorders/recording_manager_status.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | 3 | from pydantic import BaseModel, ConfigDict, SkipValidation, Field 4 | 5 | 6 | class RecordingManagerStatus(BaseModel): 7 | model_config = ConfigDict(arbitrary_types_allowed=True) 8 | is_recording_frames_flag: SkipValidation[multiprocessing.Value] = Field( 9 | default_factory=lambda: multiprocessing.Value('b', False)) 10 | should_record: SkipValidation[multiprocessing.Value] = Field( 11 | default_factory=lambda: multiprocessing.Value('b', False)) 12 | is_running_flag: SkipValidation[multiprocessing.Value] = Field( 13 | default_factory=lambda: multiprocessing.Value('b', False)) 14 | should_pause: SkipValidation[multiprocessing.Value] = Field(default_factory=lambda: multiprocessing.Value('b', False)) 15 | is_paused: SkipValidation[multiprocessing.Value] = Field(default_factory=lambda: multiprocessing.Value('b', False)) 16 | finishing: SkipValidation[multiprocessing.Value] = Field(default_factory=lambda: multiprocessing.Value('b', False)) 17 | updating: SkipValidation[multiprocessing.Value] = Field(default_factory=lambda: multiprocessing.Value('b', False)) 18 | closed: SkipValidation[multiprocessing.Value] = Field(default_factory=lambda: multiprocessing.Value('b', False)) 19 | error: SkipValidation[multiprocessing.Value] = Field(default_factory=lambda: multiprocessing.Value('b', False)) 20 | 21 | @property 22 | def recording(self) -> bool: 23 | return self.is_recording_frames_flag.value and self.should_record.value 24 | -------------------------------------------------------------------------------- /skellycam-ui/old_electron/main/index.ts: -------------------------------------------------------------------------------- 1 | import {app, BrowserWindow} from 'electron'; 2 | import {update} from './update'; 3 | import {IpcManager} from "./helpers/ipc-manager"; 4 | import {WindowManager} from "./helpers/window-manager"; 5 | import {PythonServer} from "./helpers/python-server"; 6 | import {LifecycleLogger} from "./helpers/logger"; 7 | import os from "node:os"; 8 | 9 | 10 | // Environment variables that `python` server will use for its lifecycle management 11 | process.env.SKELLYCAM_RUNNING_IN_ELECTRON = 'true'; 12 | 13 | // Platform config 14 | // Disable GPU Acceleration for Windows 7 15 | if (os.release().startsWith('6.1')) app.disableHardwareAcceleration() 16 | if (process.platform === 'win32') app.setAppUserModelId(app.getName()); 17 | 18 | // Initialization Sequence 19 | function startApplication() { 20 | LifecycleLogger.logProcessInfo(); 21 | IpcManager.initialize(); 22 | 23 | app.whenReady() 24 | .then(() => { 25 | console.log('App is ready') 26 | 27 | 28 | const mainWindow = WindowManager.createMainWindow(); 29 | 30 | update(mainWindow); 31 | }); 32 | } 33 | 34 | // Lifecycle Handlers 35 | app.on('window-all-closed', async () => { 36 | await PythonServer.shutdown(); 37 | app.quit(); 38 | LifecycleLogger.logShutdownSequence(); 39 | }); 40 | 41 | app.on('activate', () => { 42 | if (BrowserWindow.getAllWindows().length === 0) { 43 | WindowManager.createMainWindow(); 44 | } 45 | }); 46 | 47 | // Start App 48 | console.log('Starting application'); 49 | startApplication(); 50 | -------------------------------------------------------------------------------- /skellycam-ui/src/services/server/server-helpers/server-urls.ts: -------------------------------------------------------------------------------- 1 | class ServerUrls { 2 | private readonly host = 'localhost'; 3 | private readonly port = 53117; 4 | 5 | /** 6 | * Get HTTP base URL 7 | */ 8 | getHttpUrl(): string { 9 | return `http://${this.host}:${this.port}`; 10 | } 11 | 12 | /** 13 | * Get WebSocket base URL 14 | */ 15 | getWebSocketUrl(): string { 16 | return `ws://${this.host}:${this.port}/skellycam/websocket/connect`; 17 | } 18 | get endpoints() { 19 | const baseUrl = this.getHttpUrl(); 20 | 21 | return { 22 | // Server management 23 | health: `${baseUrl}/health`, 24 | shutdown: `${baseUrl}/shutdown`, 25 | 26 | // Camera endpoints 27 | detectCameras: `${baseUrl}/skellycam/camera/detect`, 28 | camerasConnectOrUpdate: `${baseUrl}/skellycam/camera/group/apply`, 29 | closeAll: `${baseUrl}/skellycam/camera/group/close/all`, 30 | updateConfigs: `${baseUrl}/skellycam/camera/update`, 31 | pauseUnpauseCameras: `${baseUrl}/skellycam/camera/group/all/pause_unpause`, 32 | 33 | // Recording endpoints 34 | startRecording: `${baseUrl}/skellycam/camera/group/all/record/start`, 35 | stopRecording: `${baseUrl}/skellycam/camera/group/all/record/stop`, 36 | 37 | // WebSocket 38 | websocket: this.getWebSocketUrl(), 39 | }; 40 | } 41 | } 42 | 43 | // Export singleton instance 44 | export const serverUrls = new ServerUrls(); 45 | -------------------------------------------------------------------------------- /skellycam-ui/src/hooks/useAppUrls.ts: -------------------------------------------------------------------------------- 1 | export interface DefaultUrlConfig { 2 | host: string; 3 | port: number; 4 | } 5 | 6 | // Default URL configuration 7 | const defaultUrlConfig: DefaultUrlConfig = { 8 | host: 'localhost', 9 | port: 8006, 10 | }; 11 | 12 | 13 | // Get the base HTTP URL 14 | const getBaseHttpUrl = () => { 15 | const {host, port} = defaultUrlConfig; 16 | return `http://${host}:${port}`; 17 | }; 18 | 19 | // Get a specific API URL 20 | const getApiUrl = (path: string) => { 21 | return `${getBaseHttpUrl()}${path}`; 22 | }; 23 | 24 | // Get WebSocket URL 25 | const getWebSocketUrl = () => { 26 | const {host, port} = defaultUrlConfig; 27 | return `ws://${host}:${port}/skellycam/websocket/connect`; 28 | }; 29 | 30 | // Get all HTTP endpoint URLs 31 | const getHttpEndpointUrls = () => { 32 | return { 33 | health: getApiUrl('/health'), 34 | shutdown: getApiUrl('/shutdown'), 35 | detectCameras: getApiUrl('/skellycam/camera/detect'), 36 | createGroup: getApiUrl('/skellycam/camera/group/apply'), 37 | closeAll: getApiUrl('/skellycam/camera/group/close/all'), 38 | updateConfigs: getApiUrl('/skellycam/camera/update'), 39 | startRecording: getApiUrl('/skellycam/camera/group/all/record/start'), 40 | stopRecording: getApiUrl('/skellycam/camera/group/all/record/stop'), 41 | pauseUnpauseCameras: getApiUrl('/skellycam/camera/group/all/pause_unpause'), 42 | }; 43 | }; 44 | 45 | export const useAppUrls = { 46 | getBaseHttpUrl, 47 | getApiUrl, 48 | getWebSocketUrl, 49 | getHttpEndpointUrls, 50 | }; 51 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/recording-info-panel/recording-subcomponents/RecordingNamePreview.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {TextField, Typography} from '@mui/material'; 3 | 4 | interface RecordingNamePreviewProps { 5 | name: string; 6 | tag: string; 7 | isRecording: boolean; 8 | onTagChange: (tag: string) => void; 9 | } 10 | 11 | export const RecordingNamePreview: React.FC = ({ 12 | name, 13 | tag, 14 | isRecording, 15 | onTagChange 16 | }) => { 17 | return ( 18 | <> 19 | 20 | Recording Name: {name} 21 | 22 | {!isRecording && ( 23 | onTagChange(e.target.value)} 27 | onKeyDown={(e) => { 28 | // Stop the TreeView from intercepting keyboard navigation 29 | e.stopPropagation();}} 30 | size="small" 31 | fullWidth 32 | placeholder="Optional tag" 33 | /> 34 | )} 35 | > 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /skellycam/core/camera/config/image_resolution.py: -------------------------------------------------------------------------------- 1 | from typing import Hashable 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class ImageResolution(BaseModel): 7 | height: int 8 | width: int 9 | 10 | @classmethod 11 | def from_string(cls, tuple_str: str, delimiter: str = "x") -> "ImageResolution": 12 | """ 13 | Create a `VideoResolution` from a string like "(720x1280)" or "(1080x1920)" consistent with rows-by-columns 14 | """ 15 | height, width = tuple_str.replace("(", "").replace(")", "").split(delimiter) 16 | return cls(height=int(height), width=int(width)) 17 | 18 | 19 | @property 20 | def aspect_ratio(self) -> float: 21 | return self.width / self.height 22 | 23 | 24 | 25 | @property 26 | def as_tuple(self) -> tuple: 27 | return self.width, self.height 28 | 29 | def __hash__(self) -> Hashable: 30 | return hash((self.height, self.width)) 31 | 32 | def __lt__(self, other: object) -> bool: 33 | """ 34 | Define this so we can sort a list of `VideoResolution`s 35 | """ 36 | if not isinstance(other, ImageResolution): 37 | return False 38 | return self.width * self.height < other.width * other.height 39 | 40 | def __eq__(self, other: object) -> bool: 41 | """ 42 | Define this so we can compare `VideoResolution`s 43 | """ 44 | if not isinstance(other, ImageResolution): 45 | return False 46 | return self.width == other.width and self.height == other.height 47 | 48 | def __str__(self) -> str: 49 | return f"({self.height}x{self.width})" 50 | 51 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/theme/theme-slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { ThemeMode } from './theme-types'; 3 | 4 | const getInitialTheme = (): ThemeMode => { 5 | if (typeof window !== 'undefined') { 6 | const saved = localStorage.getItem('themeMode'); 7 | if (saved === 'light' || saved === 'dark') { 8 | return saved; 9 | } 10 | // Check system preference 11 | if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) { 12 | return 'dark'; 13 | } 14 | } 15 | return 'dark'; 16 | }; 17 | 18 | interface ThemeState { 19 | mode: ThemeMode; 20 | systemPreference: ThemeMode | null; 21 | } 22 | 23 | const initialState: ThemeState = { 24 | mode: getInitialTheme(), 25 | systemPreference: null, 26 | }; 27 | 28 | export const themeSlice = createSlice({ 29 | name: 'theme', 30 | initialState, 31 | reducers: { 32 | themeModeSet: (state, action: PayloadAction) => { 33 | state.mode = action.payload; 34 | localStorage.setItem('themeMode', action.payload); 35 | }, 36 | themeModeToggled: (state) => { 37 | state.mode = state.mode === 'dark' ? 'light' : 'dark'; 38 | localStorage.setItem('themeMode', state.mode); 39 | }, 40 | systemPreferenceUpdated: (state, action: PayloadAction) => { 41 | state.systemPreference = action.payload; 42 | }, 43 | }, 44 | }); 45 | 46 | export const { 47 | themeModeSet, 48 | themeModeToggled, 49 | systemPreferenceUpdated, 50 | } = themeSlice.actions; 51 | -------------------------------------------------------------------------------- /skellycam/core/camera_group/timestamps/numpy_timestamps/process_recording_timestamps.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from skellycam.core.camera_group.timestamps.numpy_timestamps.calculate_timestamps_numpy import calculate_durations 4 | from skellycam.core.types.numpy_record_dtypes import FRAME_LIFECYCLE_TIMESTAMPS_DTYPE, TimestampsArray, DurationArray 5 | from skellycam.core.types.type_overloads import CameraIdString 6 | 7 | 8 | async def process_recording_timestamps( 9 | frame_metadatas_by_camera: dict[CameraIdString, list[np.recarray]], 10 | frame_numbers: list[int] 11 | ) -> tuple[TimestampsArray, DurationArray]: 12 | 13 | # Get dimensions 14 | camera_ids = list(frame_metadatas_by_camera.keys()) 15 | num_cameras = len(camera_ids) 16 | num_frames = len(frame_numbers) 17 | 18 | # Create combined array for all cameras 19 | all_timestamps = np.recarray((num_cameras, num_frames), dtype=FRAME_LIFECYCLE_TIMESTAMPS_DTYPE) 20 | 21 | # Fill the array with data from each camera on each frame 22 | for frame_number in range(num_frames): 23 | for camera_index, camera_id in enumerate(camera_ids): 24 | all_timestamps[camera_index, frame_number] = frame_metadatas_by_camera[camera_id][frame_number].timestamps[0] 25 | 26 | # Calculate durations 27 | all_durations = await calculate_durations(all_timestamps=all_timestamps) 28 | 29 | # # Calculate statistics 30 | # timestamp_statistics = calculate_frame_timestamps_statistics(all_timestamps=all_timestamps, 31 | # all_durations=all_durations) 32 | 33 | return all_timestamps, all_durations 34 | -------------------------------------------------------------------------------- /skellycam/system/logging_configuration/logging_color_helpers.py: -------------------------------------------------------------------------------- 1 | def ensure_min_brightness(value: int, threshold=100): 2 | """Ensure the RGB value is above a certain threshold.""" 3 | return max(value, threshold) 4 | 5 | 6 | def ensure_not_grey(r: int, g: int, b: int, threshold_diff: int = 100): 7 | """Ensure that the color isn't desaturated grey by making one color component dominant.""" 8 | max_val = max(r, g, b) 9 | if abs(r - g) < threshold_diff and abs(r - b) < threshold_diff and abs(g - b) < threshold_diff: 10 | if max_val == r: 11 | r = 255 12 | elif max_val == g: 13 | g = 255 14 | else: 15 | b = 255 16 | return r, g, b 17 | 18 | 19 | def ensure_not_red(r: int, g: int, b: int, threshold_diff: int = 100): 20 | """Ensure that the color isn't too red (which looks like an error).""" 21 | if r - g > threshold_diff and r - b > threshold_diff: 22 | r = int(r / 2) 23 | if g > b: 24 | g = 255 25 | else: 26 | b = 255 27 | return r, g, b 28 | 29 | 30 | def get_hashed_color(value: int): 31 | """Generate a consistent random color for the given value.""" 32 | # Use modulo to ensure it's within the range of normal terminal colors. 33 | hashed = hash(value) % 0xFFFFFF # Keep within RGB 24-bit color 34 | red = ensure_min_brightness(hashed >> 16 & 255) 35 | green = ensure_min_brightness(hashed >> 8 & 255) 36 | blue = ensure_min_brightness(hashed & 255) 37 | 38 | red, green, blue = ensure_not_grey(red, green, blue) 39 | red, green, blue = ensure_not_red(red, green, blue) 40 | 41 | return "\033[38;2;{};{};{}m".format(red, green, blue) 42 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/ui-components/BottomPanelContent.tsx: -------------------------------------------------------------------------------- 1 | // skellycam-ui/src/components/ui-components/BottomPanelContent.tsx 2 | import * as React from 'react'; 3 | import Box from '@mui/material/Box'; 4 | import {LogTerminal} from "@/components/LogTerminal"; 5 | import {Panel, PanelGroup, PanelResizeHandle} from "react-resizable-panels"; 6 | import {useTheme} from "@mui/material/styles"; 7 | import FramerateViewerPanel from "@/components/framerate-viewer/FrameRateViewer"; 8 | 9 | export default function BottomPanelContent() { 10 | const theme = useTheme(); 11 | 12 | return ( 13 | 14 | 15 | {/* Framerate Viewer Panel */} 16 | 17 | 18 | 19 | Framerate Viewer 20 | 21 | 22 | 23 | {/* Resize Handle */} 24 | 31 | 32 | {/* Logs Terminal Panel */} 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/camera-config-tree-view/CameraGroupTreeItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Typography } from "@mui/material"; 3 | import { TreeItem } from "@mui/x-tree-view/TreeItem"; 4 | import { CameraTreeItem } from "./CameraTreeItem"; 5 | import { Camera } from "@/store/slices/cameras/cameras-types"; 6 | 7 | interface CameraGroupTreeItemProps { 8 | groupId: string; 9 | title: string; 10 | cameras: Camera[]; 11 | icon?: React.ReactNode; 12 | expandedItems?: string[]; 13 | } 14 | 15 | export const CameraGroupTreeItem: React.FC = ({ 16 | groupId, 17 | title, 18 | cameras, 19 | icon, 20 | expandedItems, 21 | }) => { 22 | return ( 23 | 27 | {icon} 28 | 29 | {title} ({cameras.length}) 30 | 31 | 32 | } 33 | > 34 | {cameras.map((camera: Camera) => ( 35 | 40 | ))} 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /skellycam/core/device_detection/detect_microphone_devices.py: -------------------------------------------------------------------------------- 1 | import pyaudio 2 | 3 | 4 | def get_available_microphones(): 5 | # https://stackoverflow.com/questions/70886225/get-camera-device-name-and-port-for-opencv-videostream-python 6 | available_microphones = {} 7 | pyduo = pyaudio.PyAudio() 8 | devices_info = pyduo.get_host_api_info_by_index(0) 9 | number_of_devices = devices_info.get('deviceCount') 10 | for device_index in range(0, number_of_devices): 11 | if (pyduo.get_device_info_by_host_api_device_index(0, device_index).get('maxInputChannels')) > 0: 12 | available_microphones[device_index] = pyduo.get_device_info_by_host_api_device_index(0, device_index).get( 13 | 'name') 14 | 15 | return available_microphones 16 | 17 | 18 | # def get_available_microphone_devices() -> Dict[int, str]: 19 | # """ 20 | # List available unique microphone devices. 21 | # 22 | # Returns 23 | # ------- 24 | # list 25 | # A list of device indices for available microphones. 26 | # """ 27 | # audio = pyaudio.PyAudio() 28 | # device_count = audio.get_device_count() 29 | # unique_names = set() 30 | # microphones = {} 31 | # 32 | # for i in range(device_count): 33 | # device_info = audio.get_device_info_by_index(i) 34 | # device_name = device_info['name'] 35 | # if device_info['maxInputChannels'] > 0 and device_name not in unique_names: 36 | # # unique_names.add(device_name) 37 | # # print(f"\nDevice ID {i}: {device_name}") 38 | # # print(pprint(device_info)) 39 | # if device_name not in microphones.values(): 40 | # microphones[i] = device_name 41 | # 42 | # audio.terminate() 43 | # return microphones 44 | 45 | 46 | if __name__ == "__main__": 47 | from pprint import pprint 48 | 49 | pprint(get_available_microphones()) 50 | -------------------------------------------------------------------------------- /skellycam/system/device_detection/detect_microphone_devices.py: -------------------------------------------------------------------------------- 1 | import pyaudio 2 | 3 | 4 | def get_available_microphones(): 5 | # https://stackoverflow.com/questions/70886225/get-camera-device-name-and-port-for-opencv-videostream-python 6 | available_microphones = {} 7 | pyduo = pyaudio.PyAudio() 8 | devices_info = pyduo.get_host_api_info_by_index(0) 9 | number_of_devices = devices_info.get('deviceCount') 10 | for device_index in range(0, number_of_devices): 11 | if (pyduo.get_device_info_by_host_api_device_index(0, device_index).get('maxInputChannels')) > 0: 12 | available_microphones[device_index] = pyduo.get_device_info_by_host_api_device_index(0, device_index).get( 13 | 'name') 14 | 15 | return available_microphones 16 | 17 | 18 | # def get_available_microphone_devices() -> Dict[int, str]: 19 | # """ 20 | # List available unique microphone devices. 21 | # 22 | # Returns 23 | # ------- 24 | # list 25 | # A list of device indices for available microphones. 26 | # """ 27 | # audio = pyaudio.PyAudio() 28 | # device_count = audio.get_device_count() 29 | # unique_names = set() 30 | # microphones = {} 31 | # 32 | # for i in range(device_count): 33 | # device_info = audio.get_device_info_by_index(i) 34 | # device_name = device_info['name'] 35 | # if device_info['maxInputChannels'] > 0 and device_name not in unique_names: 36 | # # unique_names.add(device_name) 37 | # # print(f"\nDevice ID {i}: {device_name}") 38 | # # print(pprint(device_info)) 39 | # if device_name not in microphones.values(): 40 | # microphones[i] = device_name 41 | # 42 | # audio.terminate() 43 | # return microphones 44 | 45 | 46 | if __name__ == "__main__": 47 | from pprint import pprint 48 | 49 | pprint(get_available_microphones()) 50 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/common/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // Define the types for props and state 4 | interface ErrorBoundaryProps { 5 | children: React.ReactNode; 6 | } 7 | 8 | interface ErrorBoundaryState { 9 | hasError: boolean; 10 | error: Error | null; 11 | errorInfo: React.ErrorInfo | null; 12 | } 13 | 14 | class ErrorBoundary extends React.Component { 15 | constructor(props: ErrorBoundaryProps) { 16 | super(props); 17 | this.state = {hasError: false, error: null, errorInfo: null}; 18 | } 19 | 20 | static getDerivedStateFromError(error: Error): Partial { 21 | // Update state so the next render shows the fallback UI. 22 | return {hasError: true}; 23 | } 24 | 25 | componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { 26 | // You can also log the error to an error reporting service 27 | console.error('Error caught by ErrorBoundary', error, errorInfo); 28 | // Store the error and errorInfo in state 29 | this.setState({error, errorInfo}); 30 | } 31 | 32 | render() { 33 | if (this.state.hasError) { 34 | // Render a custom fallback UI with error details 35 | return ( 36 | 37 | Something went wrong :( 38 | 39 | {this.state.error && this.state.error.toString()} 40 | 41 | {this.state.errorInfo && this.state.errorInfo.componentStack} 42 | 43 | 44 | ); 45 | } 46 | 47 | return this.props.children; 48 | } 49 | } 50 | 51 | export default ErrorBoundary; 52 | // // Usage 53 | // 54 | // 55 | // 56 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/recording/recording-slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { RecordingInfo } from './recording-types'; 3 | import { startRecording, stopRecording } from './recording-thunks'; 4 | 5 | const initialState: RecordingInfo = { 6 | isRecording: false, 7 | recordingDirectory: '~/skellycam_data/recordings', 8 | recordingName: null, 9 | startedAt: null, 10 | duration: 0, 11 | }; 12 | 13 | export const recordingSlice = createSlice({ 14 | name: 'recording', 15 | initialState, 16 | reducers: { 17 | recordingInfoUpdated: (state, action: PayloadAction>) => { 18 | return { ...state, ...action.payload }; 19 | }, 20 | recordingDirectoryChanged: (state, action: PayloadAction) => { 21 | state.recordingDirectory = action.payload; 22 | }, 23 | recordingDurationUpdated: (state, action: PayloadAction) => { 24 | state.duration = action.payload; 25 | }, 26 | }, 27 | extraReducers: (builder) => { 28 | builder 29 | .addCase(startRecording.fulfilled, (state, action) => { 30 | state.isRecording = true; 31 | state.recordingName = action.meta.arg.recordingName; 32 | state.recordingDirectory = action.meta.arg.recordingDirectory; 33 | state.startedAt = new Date().toISOString(); 34 | state.duration = 0; 35 | }) 36 | .addCase(stopRecording.fulfilled, (state) => { 37 | state.isRecording = false; 38 | state.recordingName = null; 39 | state.startedAt = null; 40 | state.duration = 0; 41 | }); 42 | }, 43 | }); 44 | 45 | export const { 46 | recordingInfoUpdated, 47 | recordingDirectoryChanged, 48 | recordingDurationUpdated, 49 | } = recordingSlice.actions; 50 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/recording-info-panel/recording-subcomponents/DelayRecordingStartControl.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Box, Checkbox, FormControlLabel, TextField, useTheme} from '@mui/material'; 3 | 4 | interface DelayStartControlProps { 5 | useDelay: boolean; 6 | delaySeconds: number; 7 | onDelayToggle: (checked: boolean) => void; 8 | onDelayChange: (seconds: number) => void; 9 | } 10 | 11 | export const DelayRecordingStartControl: React.FC = ({ 12 | useDelay, 13 | delaySeconds, 14 | onDelayToggle, 15 | onDelayChange 16 | }) => { 17 | const theme = useTheme(); 18 | return ( 19 | 20 | onDelayToggle(e.target.checked)} 25 | color="primary" 26 | /> 27 | } 28 | label="Delay Start" 29 | /> 30 | {useDelay && ( 31 | onDelayChange(Math.max(1, parseInt(e.target.value) || 1))} 36 | inputProps={{min: 1, step: 1}} 37 | size="small" 38 | sx={{width: 100}} 39 | /> 40 | )} 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /skellycam/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | """Top-level package for skellycam.""" 3 | 4 | __author__ = """Skelly FreeMoCap""" 5 | __email__ = "info@freemocap.org" 6 | __version__ = "v2023.09.1086" 7 | 8 | __description__ = "A simple python API for efficiently connecting to and recording synchronized videos from one or multiple cameras 💀📸" 9 | __package_name__ = "skellycam" 10 | __repo_url__ = f"https://github.com/freemocap/{__package_name__}" 11 | __repo_issues_url__ = f"{__repo_url__}/issues" 12 | __pypi_url__ = f"https://pypi.org/project/{__package_name__}" 13 | 14 | __package_root__ = __file__.replace("/__init__.py", "") 15 | 16 | import multiprocessing 17 | multiprocessing.freeze_support() 18 | 19 | # from skellycam.api.routers import SKELLYCAM_ROUTERS 20 | # from skellycam.core.camera.config.camera_config import CameraConfig, CameraConfigs 21 | # from skellycam.core.shared_memory.multi_frame_payload_ring_buffer import \ 22 | # MultiFrameSharedMemoryRingBuffer 23 | # from skellycam.core.types.type_overloads import CameraIndex, CameraName 24 | # from skellycam.skellycam_app.skellycam_app import SkellycamApplication 25 | # from skellycam.skellycam_app.skellycam_app_ipc.ipc_manager import InterProcessCommunicationManager 26 | # from skellycam.system.logging_configuration.handlers.websocket_log_queue_handler import create_websocket_log_queue 27 | from skellycam.system.logging_configuration.configure_logging import configure_logging 28 | from skellycam.system.logging_configuration.log_levels import LogLevels 29 | 30 | LOG_LEVEL = LogLevels.TRACE 31 | configure_logging(LOG_LEVEL) 32 | 33 | 34 | __all__ = [ 35 | "__author__", 36 | "__email__", 37 | "__version__", 38 | "__description__", 39 | "__package_name__", 40 | "__repo_url__", 41 | "__repo_issues_url__", 42 | "__pypi_url__", 43 | # 'SKELLYCAM_ROUTERS', 44 | # 'SkellycamApplication', 45 | # 'MultiFrameSharedMemoryRingBuffer', 46 | # 'CameraConfig', 47 | # 'InterProcessCommunicationManager', 48 | 'LOG_LEVEL' 49 | ] -------------------------------------------------------------------------------- /skellycam/system/logging_configuration/configure_logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import multiprocessing 3 | from typing import Optional 4 | 5 | from skellycam.system.logging_configuration.handlers.websocket_log_queue_handler import create_websocket_log_queue 6 | from skellycam.system.logging_configuration.log_levels import LogLevels 7 | from skellycam.system.logging_configuration.package_log_quieters import suppress_noisy_package_logs 8 | from .log_test_messages import log_test_messages 9 | from .logger_builder import LoggerBuilder 10 | 11 | suppress_noisy_package_logs() 12 | # Add custom log levels 13 | logging.addLevelName(LogLevels.LOOP.value, "LOOP") 14 | logging.addLevelName(LogLevels.TRACE.value, "TRACE") 15 | logging.addLevelName(LogLevels.SUCCESS.value, "SUCCESS") 16 | logging.addLevelName(LogLevels.API.value, "API") 17 | 18 | 19 | def add_log_method(level: LogLevels, name: str): 20 | def log_method(self, message, *args, **kws): 21 | if self.isEnabledFor(level.value): 22 | self._log(level.value, message, args, **kws, stacklevel=2) 23 | 24 | setattr(logging.Logger, name, log_method) 25 | 26 | 27 | def configure_logging(level: LogLevels, ws_queue: Optional[multiprocessing.Queue] = None): 28 | if ws_queue is None: 29 | # do not create new queue if not main process 30 | if not multiprocessing.current_process().name.lower() == 'mainprocess': 31 | return 32 | 33 | ws_queue = create_websocket_log_queue() 34 | add_log_method(LogLevels.LOOP, 'loop') 35 | add_log_method(LogLevels.TRACE, 'trace') 36 | add_log_method(LogLevels.API, 'api') 37 | add_log_method(LogLevels.SUCCESS, 'success') 38 | 39 | builder = LoggerBuilder(level, ws_queue) 40 | builder.configure() 41 | 42 | 43 | if __name__ == "__main__": 44 | logger_test = logging.getLogger(__name__) 45 | log_test_messages(logger_test) 46 | logger_test.success( 47 | "Logging setup and tests completed. Check the console output and the log file." 48 | ) 49 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/recording/recording-thunks.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from '@reduxjs/toolkit'; 2 | import { z } from 'zod'; 3 | import { RootState } from '@/store/types'; 4 | import {serverUrls} from "@/services"; 5 | 6 | const RecordStartRequestSchema = z.object({ 7 | recording_name: z.string(), 8 | recording_directory: z.string(), 9 | mic_device_index: z.number().default(-1), 10 | }); 11 | 12 | interface StartRecordingParams { 13 | recordingName: string; 14 | recordingDirectory: string; 15 | micDeviceIndex?: number; 16 | } 17 | 18 | export const startRecording = createAsyncThunk< 19 | void, 20 | StartRecordingParams, 21 | { state: RootState } 22 | >( 23 | 'recording/start', 24 | async ({ recordingName, recordingDirectory, micDeviceIndex = -1 }, { getState }) => { 25 | const state = getState(); 26 | 27 | const payload = RecordStartRequestSchema.parse({ 28 | recording_name: recordingName, 29 | recording_directory: recordingDirectory, 30 | mic_device_index: micDeviceIndex, 31 | }); 32 | 33 | const response = await fetch(serverUrls.endpoints.startRecording, { 34 | method: 'POST', 35 | headers: { 'Content-Type': 'application/json' }, 36 | body: JSON.stringify(payload), 37 | }); 38 | 39 | if (!response.ok) { 40 | throw new Error(`Failed to start recording: ${response.statusText}`); 41 | } 42 | } 43 | ); 44 | 45 | export const stopRecording = createAsyncThunk< 46 | void, 47 | void, 48 | { state: RootState } 49 | >( 50 | 'recording/stop', 51 | async (_, { getState }) => { 52 | const state = getState(); 53 | 54 | const response = await fetch(serverUrls.endpoints.stopRecording, { 55 | method: 'GET', 56 | }); 57 | 58 | if (!response.ok) { 59 | throw new Error(`Failed to stop recording: ${response.statusText}`); 60 | } 61 | } 62 | ); 63 | -------------------------------------------------------------------------------- /skellycam-ui/src/pages/CamerasPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Box from "@mui/material/Box"; 3 | import ErrorBoundary from "@/components/common/ErrorBoundary"; 4 | import { Footer } from "@/components/ui-components/Footer"; 5 | import { useTheme } from "@mui/material/styles"; 6 | import { CameraViewsGrid } from "@/components/camera-views/CameraViewsGrid"; 7 | import {CamerasViewSettingsOverlay} from "@/components/camera-view-settings-overlay/CamerasViewSettingsOverlay"; 8 | 9 | interface CameraSettings { 10 | columns: number | null; 11 | } 12 | 13 | export const CamerasPage = () => { 14 | const theme = useTheme(); 15 | const [settings, setSettings] = useState({ 16 | columns: null, // auto 17 | }); 18 | 19 | const handleSettingsChange = (newSettings: CameraSettings) => { 20 | setSettings(newSettings); 21 | }; 22 | 23 | return ( 24 | 25 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /skellycam-ui/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type {PlaywrightTestConfig} from "@playwright/test"; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | const config: PlaywrightTestConfig = { 13 | testDir: "./e2e", 14 | /* Maximum time one test can run for. */ 15 | timeout: 30 * 1000, 16 | expect: { 17 | /** 18 | * Maximum time expect() should wait for the condition to be met. 19 | * For example in `await expect(locator).toHaveText();` 20 | */ 21 | timeout: 5000, 22 | }, 23 | /* Run tests in files in parallel */ 24 | fullyParallel: true, 25 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 26 | forbidOnly: !!process.env.CI, 27 | /* Retry on CI only */ 28 | retries: process.env.CI ? 2 : 0, 29 | /* Opt out of parallel tests on CI. */ 30 | workers: process.env.CI ? 1 : undefined, 31 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 32 | reporter: "html", 33 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 34 | use: { 35 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 36 | actionTimeout: 0, 37 | /* Base URL to use in actions like `await page.goto('/')`. */ 38 | // baseURL: 'http://localhost:3000', 39 | 40 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 41 | trace: "on-first-retry", 42 | }, 43 | 44 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 45 | // outputDir: 'test-results/', 46 | 47 | /* Run your local dev server before starting the tests */ 48 | // webServer: { 49 | // command: 'npm run start', 50 | // port: 3000, 51 | // }, 52 | }; 53 | 54 | export default config; 55 | -------------------------------------------------------------------------------- /skellycam/core/ipc/shared_memory/multi_frame_payload_single_slot_shared_memory.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from skellycam.core.frame_payloads.multiframes.multi_frame_payload import MultiFramePayload 5 | 6 | from skellycam.core.camera.config.camera_config import CameraConfigs 7 | from skellycam.core.ipc.shared_memory.shared_memory_element import SharedMemoryElement 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class MultiframePayloadSingleSlotSharedMemory(SharedMemoryElement): 13 | @classmethod 14 | def from_configs(cls, camera_configs:CameraConfigs, read_only:bool): 15 | return cls.create( 16 | dtype=MultiFramePayload.create_dummy(camera_configs=camera_configs).to_numpy_record_array().dtype, 17 | read_only=read_only, 18 | ) 19 | def put_multiframe(self, 20 | mf_payload: MultiFramePayload) -> None: 21 | if not mf_payload.full: 22 | raise ValueError("Cannot write incomplete multi-frame payload to shared memory!") 23 | 24 | for frame in mf_payload.frames.values(): 25 | frame.frame_metadata.timestamps.copy_to_multi_frame_escape_shm_buffer_timestamp_ns = time.perf_counter_ns() 26 | 27 | self.put_data(data = mf_payload.to_numpy_record_array()) 28 | 29 | def retrieve_multiframe(self) -> MultiFramePayload| None: 30 | 31 | if not self.valid: 32 | raise ValueError("Shared memory instance has been invalidated, cannot read from it!") 33 | mf_payload: MultiFramePayload|None = None 34 | mf_rec_array = self.retrieve_data() 35 | if mf_rec_array is None: 36 | return None 37 | mf_payload = MultiFramePayload.from_numpy_record_array(mf_rec_array=mf_rec_array) 38 | if not mf_payload or not mf_payload.full: 39 | raise ValueError("Did not read full multi-frame mf_payload!") 40 | for frame in mf_payload.frames.values(): 41 | frame.frame_metadata.timestamps.copy_to_multi_frame_escape_shm_buffer_timestamp_ns = time.perf_counter_ns() 42 | return mf_payload 43 | 44 | -------------------------------------------------------------------------------- /skellycam/core/camera/opencv/opencv_helpers/handle_video_recording_loop.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import numpy as np 4 | 5 | from skellycam.core.camera.config.camera_config import CameraConfig 6 | from skellycam.core.camera.opencv.opencv_helpers.handle_recording_updates import finish_recording 7 | from skellycam.core.camera_group.camera_group_ipc import CameraGroupIPC 8 | from skellycam.core.camera_group.camera_status import CameraStatus 9 | from skellycam.core.recorders.videos.video_recorder import VideoRecorder 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | def handle_video_recording(config: CameraConfig, 14 | frame_rec_array: np.recarray, 15 | ipc: CameraGroupIPC, 16 | self_status: CameraStatus, 17 | should_finish_recording: bool, 18 | should_record_frame: bool, 19 | video_recorder: VideoRecorder | None) -> VideoRecorder | None: 20 | if should_record_frame: 21 | if video_recorder is None: 22 | raise RuntimeError("Record requested before video_recorder was created.") 23 | self_status.is_recording_frame.value = True 24 | video_recorder.record_frame(frame=frame_rec_array) 25 | self_status.is_recording_frame.value = False 26 | 27 | if should_finish_recording and video_recorder is not None: 28 | if not self_status.recording_in_progress.value: 29 | raise RuntimeError("Finish recording requested but no recording in progress for camera", config.camera_id) 30 | if video_recorder is None or not video_recorder.any_data_saved: 31 | raise RuntimeError("Recording in progress but no video_recorder or no data saved for camera", 32 | config.camera_id) 33 | logger.info(f"Camera {config.camera_id} finishing and closing video recorder...") 34 | finish_recording(ipc=ipc, video_recorder=video_recorder) 35 | video_recorder = None 36 | self_status.recording_in_progress.value = False 37 | return video_recorder 38 | -------------------------------------------------------------------------------- /skellycam-ui/src/pages/VideosPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Box from "@mui/material/Box"; 3 | import ErrorBoundary from "@/components/common/ErrorBoundary"; 4 | import {Footer} from "@/components/ui-components/Footer"; 5 | import {useTheme} from "@mui/material/styles"; 6 | import {Typography} from '@mui/material'; 7 | 8 | const VideosPage: React.FC = () => { 9 | const theme = useTheme(); 10 | 11 | return ( 12 | 13 | 27 | 35 | 36 | 37 | Load Synchronized Videos 38 | 39 | 40 | This page will allow you to load and synchronize pre-recorded videos. 41 | 42 | {/* Add your video loading components here */} 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | }; 52 | 53 | export default VideosPage; 54 | -------------------------------------------------------------------------------- /skellycam-ui/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 7 | line-height: 1.5; 8 | font-weight: 400; 9 | 10 | color-scheme: light dark; 11 | color: rgba(255, 255, 255, 0.87); 12 | background-color: #242424; 13 | 14 | font-synthesis: none; 15 | text-rendering: optimizeLegibility; 16 | -webkit-font-smoothing: antialiased; 17 | -moz-osx-font-smoothing: grayscale; 18 | -webkit-text-size-adjust: 100%; 19 | } 20 | 21 | a { 22 | font-weight: 500; 23 | color: #646cff; 24 | text-decoration: inherit; 25 | } 26 | 27 | a:hover { 28 | color: #535bf2; 29 | } 30 | 31 | body { 32 | margin: 0; 33 | padding: 0; 34 | width: 100%; 35 | height: 100vh; 36 | overflow: hidden; 37 | } 38 | 39 | h1 { 40 | font-size: 3.2em; 41 | line-height: 1.1; 42 | } 43 | 44 | button { 45 | border-radius: 8px; 46 | border: 1px solid transparent; 47 | padding: 0.6em 1.2em; 48 | font-size: 1em; 49 | font-weight: 500; 50 | font-family: inherit; 51 | background-color: #1a1a1a; 52 | cursor: pointer; 53 | transition: border-color 0.25s; 54 | } 55 | 56 | button:hover { 57 | border-color: #646cff; 58 | } 59 | 60 | button:focus, 61 | button:focus-visible { 62 | outline: 4px auto -webkit-focus-ring-color; 63 | } 64 | 65 | code { 66 | background-color: #1a1a1a; 67 | padding: 2px 4px; 68 | margin: 0 4px; 69 | border-radius: 4px; 70 | } 71 | 72 | .card { 73 | padding: 2em; 74 | } 75 | 76 | #root { 77 | width: 100%; 78 | height: 100vh; 79 | overflow: hidden; 80 | } 81 | 82 | @media (prefers-color-scheme: light) { 83 | :root { 84 | color: #213547; 85 | background-color: #ffffff; 86 | } 87 | 88 | a:hover { 89 | color: #747bff; 90 | } 91 | 92 | button { 93 | background-color: #f9f9f9; 94 | } 95 | 96 | code { 97 | background-color: #f9f9f9; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /skellycam/system/logging_configuration/logger_builder.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.config import dictConfig 3 | from multiprocessing import Queue 4 | from typing import Optional 5 | 6 | from .filters.delta_time import DeltaTimeFilter 7 | from .handlers.colored_console import ColoredConsoleHandler 8 | from .handlers.websocket_log_queue_handler import WebSocketQueueHandler 9 | from .log_format_string import LOG_FORMAT_STRING 10 | from .log_levels import LogLevels 11 | from ..default_paths import get_log_file_path 12 | 13 | 14 | class LoggerBuilder: 15 | 16 | def __init__(self, 17 | level: LogLevels , 18 | queue: Optional[Queue]): 19 | self.level = level 20 | self.queue = queue 21 | dictConfig({"version": 1, "disable_existing_loggers": False}) 22 | 23 | def _configure_root_logger(self): 24 | root = logging.getLogger() 25 | root.setLevel(self.level.value) 26 | 27 | # Clear existing handlers 28 | for handler in root.handlers[:]: 29 | root.removeHandler(handler) 30 | 31 | # Add new handlers 32 | root.addHandler(self._build_file_handler()) 33 | 34 | if self.queue: 35 | root.addHandler(self._build_websocket_handler()) 36 | 37 | root.addHandler(self._build_console_handler()) 38 | 39 | 40 | def _build_console_handler(self): 41 | handler = ColoredConsoleHandler() 42 | handler.setLevel(self.level.value) 43 | return handler 44 | 45 | def _build_file_handler(self): 46 | handler = logging.FileHandler(get_log_file_path(), encoding="utf-8") 47 | handler.setFormatter(logging.Formatter(LOG_FORMAT_STRING)) 48 | handler.addFilter(DeltaTimeFilter()) # Add this line 49 | handler.setLevel(LogLevels.TRACE.value) 50 | return handler 51 | 52 | def _build_websocket_handler(self): 53 | handler = WebSocketQueueHandler(self.queue) 54 | handler.setLevel(self.level.value) 55 | return handler 56 | 57 | def configure(self): 58 | if len(logging.getLogger().handlers) == 0: 59 | self._configure_root_logger() 60 | 61 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/update/README.md: -------------------------------------------------------------------------------- 1 | # electron-updater 2 | 3 | English | [简体中文](README.zh-CN.md) 4 | 5 | > Use `electron-updater` to realize the update detection, download and installation of the electric program. 6 | 7 | ```sh 8 | npm i electron-updater 9 | ``` 10 | 11 | ### Main logic 12 | 13 | 1. ##### Configuration of the update the service address and update information script: 14 | 15 | Add a `publish` field to `electron-builder.json5` for configuring the update address and which strategy to use as the 16 | update service. 17 | 18 | ``` json5 19 | { 20 | "publish": { 21 | "provider": "generic", 22 | "channel": "latest", 23 | "url": "https://foo.com/" 24 | } 25 | } 26 | ``` 27 | 28 | For more information, please refer 29 | to : [electron-builder.json5...](https://github.com/electron-vite/electron-vite-react/blob/2f2880a9f19de50ff14a0785b32a4d5427477e55/electron-builder.json5#L38) 30 | 31 | 2. ##### The update logic of Electron: 32 | 33 | - Checking if an update is available; 34 | - Checking the version of the software on the server; 35 | - Checking if an update is available; 36 | - Downloading the new version of the software from the server (when an update is available); 37 | - Installation method; 38 | 39 | For more information, please refer 40 | to : [update...](https://github.com/electron-vite/electron-vite-react/blob/main/electron/main/update.ts) 41 | 42 | 3. ##### Updating UI pages in Electron: 43 | 44 | The main function is to provide a UI page for users to trigger the update logic mentioned in (2.) above. Users can 45 | click on the page to trigger different update functions in Electron. 46 | 47 | For more information, please refer 48 | to : [components/update...](https://github.com/electron-vite/electron-vite-react/blob/main/src/components/update/index.tsx) 49 | 50 | --- 51 | 52 | Here it is recommended to trigger updates through user actions (in this project, Electron update function is triggered 53 | after the user clicks on the "Check for updates" button). 54 | 55 | For more information on using `electron-updater` for Electron updates, please refer to the 56 | documentation : [auto-update](https://www.electron.build/.html) 57 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/camera-view-settings-overlay/ImageScaleSlider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Box, Slider, Tooltip, Typography, useTheme} from '@mui/material'; 3 | 4 | interface ImageScaleSliderProps { 5 | scale: number; 6 | onScaleChange: (value: number) => void; 7 | } 8 | 9 | const ValueLabelComponent = (props: { 10 | children: React.ReactElement; 11 | value: number; 12 | }) => { 13 | const {children, value} = props; 14 | 15 | return ( 16 | 18 | {`Scale: ${value.toFixed(1)}`} 19 | 20 | }> 21 | {children} 22 | 23 | ); 24 | }; 25 | 26 | export const ImageScaleSlider: React.FC = ({ 27 | scale = 0.5, 28 | onScaleChange, 29 | }) => { 30 | const theme = useTheme(); 31 | 32 | const marks = [ 33 | {value: 0.1, label: '0.1'}, 34 | {value: 0.5, label: '0.5 (default)'}, 35 | {value: 2.0, label: '2.0'}, 36 | ]; 37 | 38 | return ( 39 | 40 | 41 | Image Scale 42 | 43 | onScaleChange(value as number)} 51 | components={{ 52 | ValueLabel: ValueLabelComponent, 53 | }} 54 | sx={{ 55 | color: theme.palette.primary.light, 56 | '& .MuiSlider-thumb': { 57 | '&:hover, &.Mui-focusVisible': { 58 | boxShadow: `0px 0px 0px 8px ${theme.palette.primary.light}33`, 59 | }, 60 | }, 61 | }} 62 | /> 63 | 64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /skellycam/core/camera_group/timestamps/numpy_timestamps/calculate_frame_timestamp_statistics.py: -------------------------------------------------------------------------------- 1 | from skellycam.core.types.timestamp_types import TimestampStats 2 | 3 | from skellycam.core.camera_group.timestamps.numpy_timestamps.calculate_timestamps_numpy import \ 4 | calculate_frame_grab_timestamps, calculate_statistics 5 | from skellycam.core.types.numpy_record_dtypes import AllTimestampsArray, AllDurationsArray 6 | 7 | 8 | def calculate_frame_timestamps_statistics(all_timestamps: AllTimestampsArray, 9 | all_durations: AllDurationsArray) -> TimestampStats: 10 | """ 11 | Calculate statistics for all timestamp and duration fields across cameras. 12 | 13 | Args: 14 | all_timestamps: Array of timestamps with shape (num_cameras, num_frames) 15 | all_durations: Array of durations with shape (num_cameras, num_frames) 16 | 17 | Returns: 18 | Dictionary of statistics arrays for each field 19 | """ 20 | if not (all_timestamps.shape == all_durations.shape): 21 | raise ValueError("Timestamps and durations arrays must have the same shape.") 22 | # Calculate midpoints first 23 | all_frame_grab_timestamps = calculate_frame_grab_timestamps(all_timestamps) 24 | 25 | 26 | # Calculate statistics for midpoints (across cameras) 27 | frame_grab_stats = calculate_statistics(all_frame_grab_timestamps, axis=0) 28 | 29 | # Calculate statistics for all timestamp fields 30 | timestamp_stats_dict = {} 31 | for field in all_timestamps.dtype.names: 32 | if field == 'timebase_mapping': 33 | # Skip timebase_mapping field as it is not numeric 34 | continue 35 | timestamp_stats_dict[field] = calculate_statistics(all_timestamps[field], axis=0) 36 | timestamp_stats = TimestampStats(**timestamp_stats_dict) 37 | # Calculate statistics for all duration fields 38 | duration_stats = {} 39 | for field in all_durations.dtype.names: 40 | duration_stats[field] = calculate_statistics(all_durations[field], axis=0) 41 | 42 | # Combine all statistics 43 | stats = { 44 | 'frame_grab_timestamps': frame_grab_stats, 45 | 'timestamps': timestamp_stats, 46 | 'durations': duration_stats 47 | } 48 | 49 | return stats 50 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/update/Modal/modal.css: -------------------------------------------------------------------------------- 1 | .update-modal { 2 | --primary-color: rgb(224, 30, 90); 3 | 4 | .update-modal__mask { 5 | width: 100vw; 6 | height: 100vh; 7 | position: fixed; 8 | left: 0; 9 | top: 0; 10 | z-index: 9; 11 | background: rgba(0, 0, 0, 0.45); 12 | } 13 | 14 | .update-modal__warp { 15 | position: fixed; 16 | top: 50%; 17 | left: 50%; 18 | transform: translate(-50%, -50%); 19 | z-index: 19; 20 | } 21 | 22 | .update-modal__content { 23 | box-shadow: 0 0 10px -4px rgb(130, 86, 208); 24 | overflow: hidden; 25 | border-radius: 4px; 26 | 27 | .content__header { 28 | display: flex; 29 | line-height: 38px; 30 | background-color: var(--primary-color); 31 | 32 | .content__header-text { 33 | font-weight: bold; 34 | width: 0; 35 | flex-grow: 1; 36 | } 37 | } 38 | 39 | .update-modal--close { 40 | width: 30px; 41 | height: 30px; 42 | margin: 4px; 43 | line-height: 34px; 44 | text-align: center; 45 | cursor: pointer; 46 | 47 | svg { 48 | width: 17px; 49 | height: 17px; 50 | } 51 | } 52 | 53 | .content__body { 54 | padding: 10px; 55 | background-color: #fff; 56 | color: #333; 57 | } 58 | 59 | .content__footer { 60 | padding: 10px; 61 | background-color: #fff; 62 | display: flex; 63 | justify-content: flex-end; 64 | 65 | button { 66 | padding: 7px 11px; 67 | background-color: var(--primary-color); 68 | font-size: 14px; 69 | margin-left: 10px; 70 | 71 | &:first-child { 72 | margin-left: 0; 73 | } 74 | } 75 | } 76 | } 77 | 78 | .icon { 79 | padding: 0 15px; 80 | width: 20px; 81 | fill: currentColor; 82 | 83 | &:hover { 84 | color: rgba(0, 0, 0, 0.4); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /skellycam-ui/old_electron/main/helpers/window-manager.ts: -------------------------------------------------------------------------------- 1 | import {BrowserWindow, shell} from 'electron'; 2 | import {APP_PATHS} from "./app-paths"; 3 | import {APP_ENVIRONMENT} from "./app-environment"; 4 | import {LifecycleLogger} from "./logger"; 5 | 6 | export class WindowManager { 7 | static createMainWindow() { 8 | const window = new BrowserWindow({ 9 | title: 'Skellycam 💀📸', 10 | icon: APP_PATHS.SKELLYCAM_ICON_PATH, 11 | width: 1280, 12 | height: 720, 13 | webPreferences: { 14 | preload: APP_PATHS.PRELOAD, 15 | contextIsolation: true, 16 | nodeIntegration: false 17 | } 18 | }); 19 | 20 | this.configureWindowHandlers(window); 21 | this.loadContent(window); 22 | LifecycleLogger.logWindowCreation(window); 23 | return window; 24 | } 25 | 26 | private static configureWindowHandlers(window: BrowserWindow) { 27 | console.log('Configuring window handlers'); 28 | window.on('closed', () => { 29 | console.log('Window closed'); 30 | }); 31 | window.webContents.on('did-finish-load', () => { 32 | console.log('Window finished loading'); 33 | window.webContents.send('app-ready', Date.now()); 34 | }); 35 | 36 | 37 | // Intercept navigation to external links (for regular link clicks) 38 | window.webContents.on('will-navigate', (event, url) => { 39 | // Prevent navigation to external URLs and open them in default browser 40 | if (url.startsWith('http:') || url.startsWith('https:')) { 41 | event.preventDefault(); 42 | shell.openExternal(url).then(r => console.log('External link opened via navigation:', url)).catch(err => console.error('Failed to open external link via navigation:', err)); 43 | } 44 | }); 45 | 46 | } 47 | 48 | private static loadContent(window: BrowserWindow) { 49 | console.log('Loading app content - APP_ENVIRONMENT.IS_DEV:', APP_ENVIRONMENT.IS_DEV); 50 | 51 | APP_ENVIRONMENT.IS_DEV 52 | ? window.loadURL(process.env.VITE_DEV_SERVER_URL!) 53 | : window.loadFile(APP_PATHS.RENDERER_HTML); 54 | 55 | 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /skellycam-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "skellycam", 3 | "version": "2.0.0", 4 | "main": "dist-electron/main/index.js", 5 | "description": "SkellyCam - the camera backend for the FreeMoCap software.", 6 | "author": "Skelly FreeMocap ", 7 | "license": "AGPL-3.0-or-later", 8 | "debug": { 9 | "env": { 10 | "VITE_DEV_SERVER_URL": "http://127.0.0.1:7777/" 11 | } 12 | }, 13 | "type": "module", 14 | "scripts": { 15 | "dev": "cross-env NODE_ENV=development vite", 16 | "demo": "cross-env NODE_ENV=production vite build && old_electron .", 17 | "build": "tsc && vite build && electron-builder", 18 | "preview": "vite preview", 19 | "pree2e": "vite build --mode=test", 20 | "e2e": "playwright test" 21 | }, 22 | "dependencies": { 23 | "@emotion/react": "^11.13.3", 24 | "@emotion/styled": "^11.13.0", 25 | "@ffmpeg/ffmpeg": "^0.12.10", 26 | "@ffmpeg/util": "^0.12.1", 27 | "@fontsource/roboto": "^5.1.0", 28 | "@msgpack/msgpack": "^3.0.0-beta2", 29 | "@mui/icons-material": "^6.4.5", 30 | "@mui/material": "^6.1.1", 31 | "@mui/x-tree-view": "^7.29.1", 32 | "@react-three/drei": "^10.4.2", 33 | "@react-three/fiber": "^9.1.4", 34 | "@reduxjs/toolkit": "^2.5.1", 35 | "@trpc/client": "^11.5.1", 36 | "@trpc/server": "^11.5.1", 37 | "axios": "^1.7.7", 38 | "d3": "^7.9.0", 39 | "electron-updater": "^6.1.8", 40 | "exploration": "^1.6.0", 41 | "react-redux": "^9.1.2", 42 | "react-resizable-panels": "^2.1.7", 43 | "react-router": "^6.26.2", 44 | "react-router-dom": "^6.26.2", 45 | "react-use": "^17.5.1", 46 | "superjson": "^2.2.2", 47 | "three": "^0.178.0", 48 | "tree-kill": "^1.2.2", 49 | "zod": "^4.1.5" 50 | }, 51 | "devDependencies": { 52 | "@playwright/test": "^1.42.1", 53 | "@types/d3": "^7.4.3", 54 | "@types/react": "^18.2.64", 55 | "@types/react-dom": "^18.2.21", 56 | "@vitejs/plugin-react": "^4.3.3", 57 | "cross-env": "^7.0.3", 58 | "electron": "^35.0.3", 59 | "electron-builder": "^24.13.3", 60 | "electron-devtools-installer": "^4.0.0", 61 | "react": "^19.1.0", 62 | "react-dom": "^19.1.0", 63 | "vite": "^5.1.5", 64 | "vite-plugin-electron": "^0.28.8", 65 | "vite-plugin-electron-renderer": "^0.14.5" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /skellycam-ui/electron/main/app-paths.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import {fileURLToPath} from "node:url"; 3 | import {app} from "electron"; 4 | 5 | export const __dirname = path.dirname(fileURLToPath(import.meta.url)); 6 | 7 | 8 | // Function to get the correct resources path based on environment 9 | const getResourcesPath = () => { 10 | if (app.isPackaged) { 11 | const resourcesPath = path.join(process.resourcesPath, "app.asar.unpacked") 12 | console.log(`App is packaged. resourcesPath: ${resourcesPath}`); 13 | return resourcesPath; 14 | } else { 15 | const resourcesPath = path.join(__dirname, "../../"); 16 | console.log(`App is in development. resourcesPath: ${resourcesPath}`); 17 | return resourcesPath; 18 | } 19 | 20 | }; 21 | 22 | // Python server executable candidates in order of preference 23 | export const PYTHON_EXECUTABLE_CANDIDATES = [ 24 | { 25 | name: 'development', 26 | path: path.join(getResourcesPath(), '../dist/skellycam_server.exe'), 27 | description: 'Development build executable' 28 | }, 29 | { 30 | name: 'installed', 31 | path: path.join(getResourcesPath(), 'skellycam_server.exe'), 32 | description: 'Executable in the installation folder' 33 | }, 34 | 35 | { 36 | name: 'portable', 37 | path: path.join(process.cwd(), 'skellycam_server.exe'), 38 | description: 'Portable executable in current directory' 39 | }, 40 | { 41 | name: 'system-path', 42 | path: 'skellycam_server.exe', // Will be found via PATH 43 | description: 'Executable available in system PATH' 44 | } 45 | ]; 46 | 47 | export const APP_PATHS = { 48 | PRELOAD: path.join(__dirname, "../preload/index.mjs"), 49 | RENDERER_HTML: path.join(__dirname, "../../dist/index.html"), 50 | SKELLYCAM_ICON_PATH: path.resolve( 51 | __dirname, 52 | "../../../shared/skellycam-logo/skellycam-favicon.ico" 53 | ), 54 | SKELLYCAM_LOGO_PNG_RESOURCES_PATH: path.join(getResourcesPath(), 'dist/skellycam-logo.png'), 55 | SKELLYCAM_LOGO_PNG_SHARED_PATH:path.resolve( 56 | __dirname, 57 | "../../../shared/skellycam-logo/skellycam-logo.png" 58 | ), 59 | 60 | }; 61 | 62 | 63 | console.log(`APP_PATHS: ${JSON.stringify(APP_PATHS, null, 2)}`); -------------------------------------------------------------------------------- /skellycam-ui/old_electron/main/helpers/app-paths.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import {fileURLToPath} from "node:url"; 3 | import {app} from "electron"; 4 | 5 | export const __dirname = path.dirname(fileURLToPath(import.meta.url)); 6 | 7 | 8 | // Function to get the correct resources path based on environment 9 | const getResourcesPath = () => { 10 | if (app.isPackaged) { 11 | const resourcesPath = path.join(process.resourcesPath, "app.asar.unpacked") 12 | console.log(`App is packaged. resourcesPath: ${resourcesPath}`); 13 | return resourcesPath; 14 | } else { 15 | const resourcesPath = path.join(__dirname, "../../"); 16 | console.log(`App is in development. resourcesPath: ${resourcesPath}`); 17 | return resourcesPath; 18 | } 19 | 20 | }; 21 | 22 | // Python server executable candidates in order of preference 23 | export const PYTHON_EXECUTABLE_CANDIDATES = [ 24 | { 25 | name: 'development', 26 | path: path.join(getResourcesPath(), '../dist/skellycam_server.exe'), 27 | description: 'Development build executable' 28 | }, 29 | { 30 | name: 'installed', 31 | path: path.join(getResourcesPath(), 'skellycam_server.exe'), 32 | description: 'Executable in the installation folder' 33 | }, 34 | 35 | { 36 | name: 'portable', 37 | path: path.join(process.cwd(), 'skellycam_server.exe'), 38 | description: 'Portable executable in current directory' 39 | }, 40 | { 41 | name: 'system-path', 42 | path: 'skellycam_server.exe', // Will be found via PATH 43 | description: 'Executable available in system PATH' 44 | } 45 | ]; 46 | 47 | export const APP_PATHS = { 48 | PRELOAD: path.join(__dirname, "../preload/index.mjs"), 49 | RENDERER_HTML: path.join(__dirname, "../../dist/index.html"), 50 | SKELLYCAM_ICON_PATH: path.resolve( 51 | __dirname, 52 | "../../../shared/skellycam-logo/skellycam-favicon.ico" 53 | ), 54 | SKELLYCAM_LOGO_PNG_RESOURCES_PATH: path.join(getResourcesPath(), 'dist/skellycam-logo.png'), 55 | SKELLYCAM_LOGO_PNG_SHARED_PATH:path.resolve( 56 | __dirname, 57 | "../../../shared/skellycam-logo/skellycam-logo.png" 58 | ), 59 | 60 | }; 61 | 62 | 63 | console.log(`APP_PATHS: ${JSON.stringify(APP_PATHS, null, 2)}`); -------------------------------------------------------------------------------- /skellycam/core/camera/opencv/opencv_helpers/opencv_extract_config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from sys import platform 3 | 4 | import cv2 5 | 6 | from skellycam.core.camera.config.camera_config import CameraConfig 7 | from skellycam.core.camera.config.image_resolution import ImageResolution 8 | from skellycam.core.camera.config.image_rotation_types import RotationTypes 9 | from skellycam.core.types.type_overloads import CameraIndexInt 10 | from skellycam.system.diagnostics.recommend_camera_exposure_setting import ExposureModes 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def extract_config_from_cv2_capture(camera_index: CameraIndexInt, 16 | camera_id: str, 17 | camera_name: str, 18 | cv2_capture: cv2.VideoCapture, 19 | exposure_mode: str = ExposureModes.MANUAL.name, 20 | rotation: RotationTypes = RotationTypes.NO_ROTATION, ) -> CameraConfig: 21 | width = int(cv2_capture.get(cv2.CAP_PROP_FRAME_WIDTH)) 22 | height = int(cv2_capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) 23 | exposure = int(cv2_capture.get(cv2.CAP_PROP_EXPOSURE)) 24 | framerate = cv2_capture.get(cv2.CAP_PROP_FPS) 25 | 26 | if any([ 27 | width == 0, 28 | height == 0, 29 | not (platform == "darwin") and exposure == 0 # macOS always returns 0 for exposure 30 | ]): 31 | logger.error(f"Failed to extract configuration from cv2.VideoCapture object - " 32 | f"width: {width}, height: {height}, exposure: {exposure}") 33 | raise ValueError("Invalid camera configuration detected. Please check the camera settings.") 34 | try: 35 | return CameraConfig( 36 | camera_index=camera_index, 37 | camera_id=camera_id, 38 | camera_name=camera_name, 39 | resolution=ImageResolution( 40 | width=width, 41 | height=height 42 | ), 43 | exposure_mode=exposure_mode, 44 | exposure=exposure, 45 | framerate=framerate, 46 | rotation=rotation, 47 | ) 48 | except Exception as e: 49 | logger.error(f"Failed to extract configuration from cv2.VideoCapture object - {type(e).__name__}: {e}") 50 | raise 51 | -------------------------------------------------------------------------------- /skellycam/core/ipc/shared_memory/frame_payload_shared_memory_ring_buffer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import numpy as np 4 | 5 | from skellycam.core.camera.config.camera_config import CameraConfig 6 | from skellycam.core.camera_group.timestamps.timebase_mapping import TimebaseMapping 7 | from skellycam.core.ipc.shared_memory.ring_buffer_shared_memory import SharedMemoryRingBuffer 8 | from skellycam.core.types.numpy_record_dtypes import create_frame_dtype 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class FramePayloadSharedMemoryRingBuffer(SharedMemoryRingBuffer): 14 | 15 | @classmethod 16 | def from_config(cls, 17 | camera_config: CameraConfig, 18 | timebase_mapping: TimebaseMapping, 19 | read_only: bool = False): 20 | # Create a dummy frame record array for shape and dtype 21 | frame_dtype = create_frame_dtype(camera_config) 22 | dummy_frame = np.recarray(1, dtype=frame_dtype) 23 | 24 | # Initialize the frame metadata 25 | dummy_frame.frame_metadata.camera_config = camera_config.to_numpy_record_array()[0] 26 | dummy_frame.frame_metadata.frame_number = -99 27 | dummy_frame.frame_metadata.timebase_mapping = timebase_mapping.to_numpy_record_array()[0] 28 | 29 | # Initialize the image with zeros 30 | image_shape = (camera_config.resolution.height, camera_config.resolution.width, camera_config.color_channels) 31 | dummy_frame.image[0] = np.zeros(image_shape, dtype=np.uint8) 32 | 33 | return cls.create( 34 | example_data=dummy_frame, 35 | read_only=read_only, 36 | ) 37 | 38 | @property 39 | def new_frame_available(self): 40 | return self.new_data_available 41 | 42 | def put_frame(self, frame_rec_array: np.recarray, overwrite: bool): 43 | if self.read_only: 44 | raise ValueError("Cannot put new frame into read-only instance of shared memory!") 45 | self.put_data(frame_rec_array, overwrite_allowed=overwrite) 46 | 47 | def retrieve_latest_frame(self, frame_rec_array:np.recarray) -> np.recarray: 48 | frame_rec_array = self.get_latest_data(frame_rec_array) 49 | return frame_rec_array 50 | 51 | def retrieve_next_frame(self, frame_rec_array:np.recarray) -> np.recarray: 52 | frame_rec_array = self.get_next_data(frame_rec_array) 53 | return frame_rec_array -------------------------------------------------------------------------------- /skellycam-ui/electron/main/services/update-handler.ts: -------------------------------------------------------------------------------- 1 | 2 | import { app, ipcMain, BrowserWindow } from 'electron'; 3 | import { autoUpdater } from 'electron-updater'; 4 | 5 | export class UpdateHandler { 6 | private mainWindow: BrowserWindow; 7 | 8 | constructor(window: BrowserWindow) { 9 | this.mainWindow = window; 10 | this.setupAutoUpdater(); 11 | this.registerIpcHandlers(); 12 | } 13 | 14 | private setupAutoUpdater() { 15 | autoUpdater.autoDownload = false; 16 | autoUpdater.disableWebInstaller = false; 17 | autoUpdater.allowDowngrade = false; 18 | 19 | autoUpdater.on('checking-for-update', () => { 20 | console.log('Checking for updates...'); 21 | }); 22 | 23 | autoUpdater.on('update-available', (info) => { 24 | console.log('Update available:', info); 25 | this.mainWindow.webContents.send('update-available', { 26 | version: info.version, 27 | currentVersion: app.getVersion(), 28 | }); 29 | }); 30 | 31 | autoUpdater.on('update-not-available', () => { 32 | console.log('No updates available'); 33 | }); 34 | 35 | autoUpdater.on('download-progress', (progress) => { 36 | this.mainWindow.webContents.send('download-progress', progress); 37 | }); 38 | 39 | autoUpdater.on('update-downloaded', () => { 40 | this.mainWindow.webContents.send('update-downloaded'); 41 | }); 42 | 43 | autoUpdater.on('error', (error) => { 44 | console.error('Update error:', error); 45 | this.mainWindow.webContents.send('update-error', error); 46 | }); 47 | } 48 | 49 | private registerIpcHandlers() { 50 | ipcMain.handle('check-update', async () => { 51 | if (!app.isPackaged) { 52 | return { 53 | error: 'Updates only work in packaged app', 54 | available: false 55 | }; 56 | } 57 | return await autoUpdater.checkForUpdatesAndNotify(); 58 | }); 59 | 60 | ipcMain.handle('download-update', async () => { 61 | return await autoUpdater.downloadUpdate(); 62 | }); 63 | 64 | ipcMain.handle('install-update', () => { 65 | autoUpdater.quitAndInstall(false, true); 66 | }); 67 | } 68 | 69 | static initialize(window: BrowserWindow) { 70 | return new UpdateHandler(window); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/framerate/framerate-slice.ts: -------------------------------------------------------------------------------- 1 | // framerate-slice.ts 2 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 3 | import { DetailedFramerate } from './framerate-types'; 4 | 5 | // Export DetailedFramerate as CurrentFramerate for component compatibility 6 | export type CurrentFramerate = DetailedFramerate; 7 | 8 | interface FramerateState { 9 | currentBackendFramerate: DetailedFramerate | null; 10 | currentFrontendFramerate: DetailedFramerate | null; 11 | // Arrays for historical data to calculate averages and render charts 12 | recentFrontendFrameDurations: number[]; 13 | recentBackendFrameDurations: number[]; 14 | } 15 | 16 | const initialState: FramerateState = { 17 | currentBackendFramerate: null, 18 | currentFrontendFramerate: null, 19 | recentFrontendFrameDurations: [], 20 | recentBackendFrameDurations: [], 21 | }; 22 | 23 | const MAX_DURATION_HISTORY = 100; // Keep last 100 frame durations 24 | 25 | export const framerateSlice = createSlice({ 26 | name: 'framerate', 27 | initialState, 28 | reducers: { 29 | backendFramerateUpdated: (state, action: PayloadAction) => { 30 | state.currentBackendFramerate = action.payload; 31 | // Add mean frame duration to history 32 | if (action.payload.mean_frame_duration_ms > 0) { 33 | state.recentBackendFrameDurations = [ 34 | ...state.recentBackendFrameDurations.slice(-(MAX_DURATION_HISTORY - 1)), 35 | action.payload.mean_frame_duration_ms 36 | ]; 37 | } 38 | }, 39 | frontendFramerateUpdated: (state, action: PayloadAction) => { 40 | state.currentFrontendFramerate = action.payload; 41 | // Add mean frame duration to history 42 | if (action.payload.mean_frame_duration_ms > 0) { 43 | state.recentFrontendFrameDurations = [ 44 | ...state.recentFrontendFrameDurations.slice(-(MAX_DURATION_HISTORY - 1)), 45 | action.payload.mean_frame_duration_ms 46 | ]; 47 | } 48 | }, 49 | clearFramerateHistory: (state) => { 50 | state.recentFrontendFrameDurations = []; 51 | state.recentBackendFrameDurations = []; 52 | }, 53 | }, 54 | }); 55 | 56 | export const { 57 | backendFramerateUpdated, 58 | frontendFramerateUpdated, 59 | clearFramerateHistory 60 | } = framerateSlice.actions; 61 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/thunks/update-camera-configs-thunk.ts: -------------------------------------------------------------------------------- 1 | import {createAsyncThunk} from "@reduxjs/toolkit"; 2 | import { 3 | selectConfigsForSelectedCameras, 4 | setError, 5 | setLoading, 6 | updateCameraConfigs 7 | } from "@/store/slices/cameras-slices/camerasSlice"; 8 | import {CameraConfig} from "../slices/cameras-slices/camera-types"; 9 | import {useAppUrls} from "@/hooks/useAppUrls"; 10 | 11 | export const updateCameraConfigsThunk = createAsyncThunk( 12 | 'camera/update', 13 | async (_, {dispatch, getState}) => { 14 | const state = getState() as any; 15 | dispatch(setLoading(true)); 16 | const updateConfigsUrl = useAppUrls.getHttpEndpointUrls().updateConfigs; 17 | 18 | const payload = { 19 | camera_configs: selectConfigsForSelectedCameras(state) 20 | }; 21 | 22 | const requestBody = JSON.stringify(payload, null, 2); 23 | try { 24 | console.log(`Updating Camera Configs at ${updateConfigsUrl} with request body keys:`, Object.keys(payload)); 25 | const response = await fetch(updateConfigsUrl, { 26 | method: 'PUT', 27 | headers: { 28 | 'Content-Type': 'application/json', 29 | }, 30 | body: requestBody 31 | }); 32 | 33 | // Parse the response body 34 | const data = await response.json(); 35 | 36 | // Check for different error status codes 37 | if (!response.ok) { 38 | let errorMsg = 'Failed to connect to cameras'; 39 | console.error('Errors:', data.detail); 40 | dispatch(setError(errorMsg)); 41 | throw new Error(errorMsg); 42 | } 43 | // Convert the response data to a Record 44 | dispatch(updateCameraConfigs(data.camera_configs as Record)); 45 | dispatch(setError(null)); 46 | return data; 47 | } catch (error) { 48 | // Handle network errors and JSON parsing errors 49 | const errorMessage = error instanceof Error 50 | ? `Failed to connect to cameras: ${error.message}` 51 | : 'Failed to connect to cameras: Unknown error'; 52 | 53 | dispatch(setError(errorMessage)); 54 | console.error(errorMessage, error); 55 | throw error; 56 | } finally { 57 | dispatch(setLoading(false)); 58 | } 59 | } 60 | ); 61 | -------------------------------------------------------------------------------- /skellycam-ui/src/layout/BasePanelLayout.tsx: -------------------------------------------------------------------------------- 1 | // skellycam-ui/src/layout/BasePanelLayout.tsx 2 | import React from "react"; 3 | import {Panel, PanelGroup, PanelResizeHandle} from "react-resizable-panels"; 4 | import {LeftSidePanelContent} from "@/components/ui-components/LeftSidePanelContent"; 5 | import BottomPanelContent from "@/components/ui-components/BottomPanelContent"; 6 | import {useTheme} from "@mui/material/styles"; 7 | import {Box} from "@mui/material"; 8 | import {useLocation} from "react-router-dom"; 9 | 10 | export const BasePanelLayout = ({children}: { children: React.ReactNode }) => { 11 | const theme = useTheme(); 12 | const location = useLocation(); 13 | 14 | return ( 15 | 16 | 20 | {/* Top section (horizontal panels) - 80% height */} 21 | 22 | 23 | 24 | 25 | 26 | {/* Horizontal Resize Handle */} 27 | 34 | 35 | {/*Main/Central Content Panel*/} 36 | 37 | {children} 38 | 39 | 40 | 41 | 42 | {/* Vertical Resize Handle */} 43 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /skellycam/core/camera/opencv/opencv_helpers/create_cv2_video_capture.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import cv2 4 | 5 | from skellycam.core.camera.config.camera_config import CameraConfig 6 | from skellycam.core.camera.opencv.opencv_helpers.determine_backend import determine_opencv_camera_backend 7 | from skellycam.core.camera.opencv.opencv_helpers.opencv_apply_config import apply_camera_configuration 8 | from skellycam.utilities.wait_functions import wait_1s 9 | 10 | 11 | class FailedToReadFrameFromCameraException(Exception): 12 | pass 13 | 14 | 15 | class FailedToOpenCameraException(Exception): 16 | pass 17 | 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | def create_cv2_video_capture(config: CameraConfig, retry_count: int = 5) -> tuple[cv2.VideoCapture, CameraConfig]: 23 | cap_backend = determine_opencv_camera_backend() 24 | attempts = -1 25 | capture: cv2.VideoCapture | None = None 26 | while attempts < retry_count and capture is None: 27 | attempts += 1 28 | capture = cv2.VideoCapture(int(config.camera_index), cap_backend.id) 29 | if not capture.isOpened(): 30 | if attempts < retry_count: 31 | logger.warning( 32 | f"Failed to open camera {config.camera_index}. Retrying... ({attempts + 1}/{retry_count})") 33 | capture.release() 34 | wait_1s() 35 | capture = None 36 | continue 37 | raise FailedToOpenCameraException() 38 | success, image = capture.read() 39 | 40 | if not success or image is None: 41 | if attempts < retry_count: 42 | logger.warning( 43 | f"Failed to read frame from camera {config.camera_index}. Retrying... ({attempts + 1}/{retry_count})") 44 | capture.release() 45 | wait_1s() 46 | capture = None 47 | continue 48 | raise FailedToReadFrameFromCameraException() 49 | if not isinstance(capture, cv2.VideoCapture) or not capture.isOpened(): 50 | raise FailedToOpenCameraException(f"Failed to open camera {config.camera_index} after {retry_count} attempts.") 51 | extracted_config = apply_camera_configuration(cv2_vid_capture=capture, 52 | prior_config=None, 53 | config=config) 54 | logger.info(f"Created `cv2.VideoCapture` object for Camera: {config.camera_index}") 55 | return capture, extracted_config 56 | -------------------------------------------------------------------------------- /skellycam/api/http/videos/videos_router.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | from fastapi import APIRouter, HTTPException, Body 5 | from pydantic import BaseModel, Field 6 | 7 | from skellycam.skellycam_app.skellycam_app import get_skellycam_app 8 | 9 | logger = logging.getLogger(__name__) 10 | load_videos_router = APIRouter() 11 | DEFAULT_VIDEOS_PATH = Path().home() / "freemocap_data" / "recording_sessions" / "freemocap_test_data" / "synchronized_videos" 12 | 13 | def get_test_videos() -> list[str]: 14 | # case insensitive glob all video files in the directory 15 | video_paths = [] 16 | for ext in ['*.mp4', '*.avi', '*.mov']: 17 | for video_path in DEFAULT_VIDEOS_PATH.glob(ext): 18 | video_paths.append(str(video_path)) 19 | return video_paths 20 | 21 | class LoadRecordingRequest(BaseModel): 22 | video_paths: list[str] = Field(default_factory=get_test_videos, description="List of paths to synchronized videos that all have exactly the same number of frames") 23 | 24 | class LoadRecordingResponse(BaseModel): 25 | frame_count: int = Field(default=0, ge=0, description="Total number of frames across all videos in the recording session.") 26 | 27 | 28 | @load_videos_router.post("/load_videos", response_model=LoadRecordingResponse, tags=["Videos"]) 29 | async def load_recording_endpoint(request: LoadRecordingRequest = Body(..., description="Request body containing the path to the recording directory", 30 | examples=[LoadRecordingRequest()]) 31 | ) -> LoadRecordingResponse: 32 | logger.info(f"Loading recording from path: {request.recording_path}") 33 | if not Path(request.recording_path).is_dir(): 34 | error_msg = f"Recording path does not exist or is not a directory: {request.recording_path}" 35 | logger.error(error_msg) 36 | raise HTTPException(status_code=400, detail=error_msg) 37 | try: 38 | get_skellycam_app().set_recording_path(str(request.recording_path)) 39 | return LoadRecordingResponse.from_app_state(get_skellyclicker_app_state()) 40 | except ValueError as e: 41 | error_msg = f"Invalid recording path: {str(e)}" 42 | logger.error(error_msg) 43 | raise HTTPException(status_code=400, detail=error_msg) 44 | except Exception as e: 45 | error_msg = f"Failed to load recording: {type(e).__name__} - {str(e)}" 46 | logger.exception(error_msg) 47 | raise HTTPException(status_code=500, detail=error_msg) 48 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/camera-config-panel/CameraConfigRotation.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ToggleButton from '@mui/material/ToggleButton'; 3 | import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; 4 | import { Box, Tooltip, useTheme } from '@mui/material'; 5 | import {ROTATION_DEGREE_LABELS, ROTATION_OPTIONS, RotationValue} from '@/store/slices/cameras/cameras-types'; 6 | 7 | interface CameraConfigRotationProps { 8 | rotation?: RotationValue; 9 | onChange: (rotation: RotationValue) => void; 10 | } 11 | 12 | 13 | 14 | export const CameraConfigRotation: React.FC = ({ 15 | rotation = -1, 16 | onChange 17 | }) => { 18 | const theme = useTheme(); 19 | 20 | const handleChange = ( 21 | event: React.MouseEvent, 22 | newRotation: RotationValue | null, 23 | ): void => { 24 | if (newRotation !== null) { 25 | onChange(newRotation); 26 | } 27 | }; 28 | 29 | return ( 30 | 31 | 32 | 50 | {ROTATION_OPTIONS.map((option: RotationValue) => ( 51 | 52 | {ROTATION_DEGREE_LABELS[option]} 53 | 54 | ))} 55 | 56 | 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /skellycam-ui/electron/main/index.ts: -------------------------------------------------------------------------------- 1 | // electron/main/index.ts 2 | import { app, BrowserWindow } from 'electron'; 3 | import { setupIPC } from './ipc'; 4 | import { WindowManager } from './services/window-manager'; 5 | import { PythonServer } from './services/python-server'; 6 | // import { UpdateHandler } from './services/update-handler'; 7 | import { LifecycleLogger } from './services/logger'; 8 | // import os from 'node:os'; // Uncomment if needed for platform-specific checks 9 | 10 | // Export environment configuration 11 | export const APP_ENVIRONMENT = { 12 | IS_DEV: process.env.NODE_ENV === 'development', 13 | VITE_DEV_SERVER_URL: process.env.VITE_DEV_SERVER_URL, 14 | }; 15 | 16 | 17 | // Platform config 18 | // if (os.release().startsWith('6.1')) app.disableHardwareAcceleration(); 19 | if (process.platform === 'win32') app.setAppUserModelId(app.getName()); 20 | 21 | // Prevent multiple instances 22 | const gotTheLock = app.requestSingleInstanceLock(); 23 | 24 | if (!gotTheLock) { 25 | app.quit(); 26 | } else { 27 | app.on('second-instance', () => { 28 | // Someone tried to run a second instance, focus our window instead 29 | const windows = BrowserWindow.getAllWindows(); 30 | if (windows.length > 0) { 31 | const window = windows[0]; 32 | if (window.isMinimized()) window.restore(); 33 | window.focus(); 34 | } 35 | }); 36 | 37 | // App lifecycle 38 | app.whenReady().then(async () => { 39 | LifecycleLogger.logProcessInfo(); 40 | console.log('App is ready'); 41 | 42 | // Setup IPC 43 | setupIPC(); 44 | 45 | // Create window 46 | const mainWindow = WindowManager.createMainWindow(); 47 | 48 | //TODO: Re-enable auto-updates 49 | // // Initialize auto-updater (only in production) 50 | // if (!APP_ENVIRONMENT.IS_DEV) { 51 | // UpdateHandler.initialize(mainWindow); 52 | // } 53 | 54 | }); 55 | 56 | app.on('window-all-closed', async () => { 57 | console.log('All windows closed, shutting down...'); 58 | await PythonServer.shutdown(); 59 | app.quit(); 60 | }); 61 | 62 | app.on('activate', () => { 63 | if (BrowserWindow.getAllWindows().length === 0) { 64 | WindowManager.createMainWindow(); 65 | } 66 | }); 67 | 68 | app.on('before-quit', async (event) => { 69 | event.preventDefault(); 70 | console.log('App is quitting, cleaning up...'); 71 | await PythonServer.shutdown(); 72 | app.exit(0); 73 | }); 74 | } 75 | -------------------------------------------------------------------------------- /skellycam/tests/test_descriptive_statistics.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from skellycam.utilities.descriptive_statistics import DescriptiveStatistics 5 | 6 | 7 | def test_descriptive_statistics_single_sample(): 8 | """Test that DescriptiveStatistics handles a single sample correctly.""" 9 | # Create a DescriptiveStatistics object with a single sample 10 | single_sample = [42.0] 11 | stats = DescriptiveStatistics.from_samples(single_sample, name="Single Sample", units="units") 12 | 13 | # Check measures of central tendency 14 | assert stats.mean == 42.0 15 | assert stats.median == 42.0 16 | 17 | # Check measures of variability (should all be zero) 18 | assert stats.standard_deviation == 0.0 19 | assert stats.median_absolute_deviation == 0.0 20 | assert stats.interquartile_range == 0.0 21 | assert stats.confidence_interval_95 == 0.0 22 | assert stats.coefficient_of_variation == 0.0 23 | 24 | # Check other properties 25 | assert stats.max == 42.0 26 | assert stats.min == 42.0 27 | assert stats.max_index == 0 28 | assert stats.min_index == 0 29 | assert stats.number_of_samples == 1 30 | 31 | 32 | def test_descriptive_statistics_multiple_samples(): 33 | """Test that DescriptiveStatistics handles multiple samples correctly.""" 34 | # Create a DescriptiveStatistics object with multiple samples 35 | multiple_samples = [1.0, 2.0, 3.0, 4.0, 5.0] 36 | stats = DescriptiveStatistics.from_samples(multiple_samples, name="Multiple Samples", units="units") 37 | 38 | # Check measures of central tendency 39 | assert stats.mean == 3.0 40 | assert stats.median == 3.0 41 | 42 | # Check measures of variability (should be calculated) 43 | assert stats.standard_deviation == pytest.approx(np.std(multiple_samples)) 44 | assert stats.median_absolute_deviation == pytest.approx(np.median(np.abs(np.array(multiple_samples) - 3.0))) 45 | assert stats.interquartile_range == pytest.approx(4.0 - 2.0) # Q3 - Q1 46 | 47 | # Check other properties 48 | assert stats.max == 5.0 49 | assert stats.min == 1.0 50 | assert stats.max_index == 4 51 | assert stats.min_index == 0 52 | assert stats.number_of_samples == 5 53 | 54 | 55 | def test_descriptive_statistics_empty_samples(): 56 | """Test that DescriptiveStatistics raises an error for empty samples.""" 57 | # Try to create a DescriptiveStatistics object with empty samples 58 | with pytest.raises(ValueError): 59 | DescriptiveStatistics.from_samples([], name="Empty Samples", units="units") -------------------------------------------------------------------------------- /skellycam-ui/src/components/PauseUnpauseButton.tsx: -------------------------------------------------------------------------------- 1 | // skellycam-ui/src/components/PauseUnpauseButton.tsx 2 | import React, {useState} from 'react'; 3 | import {Button, CircularProgress, keyframes, Tooltip} from '@mui/material'; 4 | import {styled} from '@mui/system'; 5 | import PlayArrowIcon from '@mui/icons-material/PlayArrow'; 6 | import PauseIcon from '@mui/icons-material/Pause'; 7 | import {pauseUnpauseThunk} from "@/store/slices/cameras/old-camera-thunks/pause-unpause-thunk"; 8 | 9 | interface PauseUnpauseButtonProps { 10 | disabled?: boolean; 11 | } 12 | 13 | const pulseAnimation = keyframes` 14 | 0% { 15 | background-color: rgba(25, 118, 210, 0.8); 16 | } 17 | 50% { 18 | background-color: rgba(25, 118, 210, 1); 19 | } 20 | 100% { 21 | background-color: rgba(25, 118, 210, 0.8); 22 | } 23 | `; 24 | 25 | const PulsingButton = styled(Button)<{ pulsing?: boolean }>(({pulsing}) => ({ 26 | // backgroundColor: '#1976d2', // MUI primary blue 27 | borderRadius: '80px', 28 | padding: '16px', 29 | '&:hover': { 30 | backgroundColor: '#1565c0', // Darker blue on hover 31 | }, 32 | ...(pulsing && { 33 | animation: `${pulseAnimation} 1.5s infinite ease-in-out`, 34 | }), 35 | 36 | })); 37 | 38 | export const PauseUnpauseButton: React.FC = ({ 39 | disabled = false 40 | }) => { 41 | const [isPaused, setIsPaused] = useState(false); 42 | const [isLoading, setIsLoading] = useState(false); 43 | 44 | 45 | const handleClick = async () => { 46 | setIsLoading(true); 47 | 48 | try { 49 | await pauseUnpauseThunk(); 50 | // Toggle the paused state after successful API call 51 | setIsPaused(prev => !prev); 52 | } catch (error) { 53 | console.error('Failed to pause/unpause cameras:', error); 54 | } finally { 55 | setIsLoading(false); 56 | } 57 | }; 58 | 59 | return ( 60 | 61 | : isPaused ? : } 68 | > 69 | 70 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /skellycam/core/ipc/shared_memory/camera_shared_memory_ring_buffer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import numpy as np 4 | 5 | from skellycam.core.camera.config.camera_config import CameraConfig 6 | from skellycam.core.camera_group.timestamps.timebase_mapping import TimebaseMapping 7 | from skellycam.core.ipc.shared_memory.ring_buffer_shared_memory import SharedMemoryRingBuffer 8 | from skellycam.core.types.numpy_record_dtypes import create_frame_dtype 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class CameraSharedMemoryRingBuffer(SharedMemoryRingBuffer): 14 | 15 | @property 16 | def latest_frame_number(self) -> int: 17 | if not self.valid: 18 | raise ValueError("Shared memory is not valid!") 19 | return self.last_written_index.value 20 | 21 | @classmethod 22 | def from_config(cls, 23 | camera_config: CameraConfig, 24 | timebase_mapping: TimebaseMapping, 25 | read_only: bool = False): 26 | # Create a dummy frame record array for shape and dtype 27 | frame_dtype = create_frame_dtype(camera_config) 28 | dummy_frame = np.recarray(1, dtype=frame_dtype) 29 | 30 | # Initialize the frame metadata 31 | dummy_frame.frame_metadata.camera_config = camera_config.to_numpy_record_array()[0] 32 | dummy_frame.frame_metadata.frame_number = -99 33 | dummy_frame.frame_metadata.timebase_mapping = timebase_mapping.to_numpy_record_array()[0] 34 | 35 | # Initialize the image with zeros 36 | image_shape = (camera_config.resolution.height, camera_config.resolution.width, camera_config.color_channels) 37 | dummy_frame.image[0] = np.zeros(image_shape, dtype=np.uint8) 38 | 39 | return cls.create( 40 | example_data=dummy_frame, 41 | read_only=read_only, 42 | ) 43 | 44 | @property 45 | def new_frame_available(self): 46 | return self.new_data_available 47 | 48 | def put_frame(self, frame_rec_array: np.recarray, overwrite: bool): 49 | if self.read_only: 50 | raise ValueError("Cannot put new frame into read-only instance of shared memory!") 51 | self.put_data(frame_rec_array, overwrite_allowed=overwrite) 52 | 53 | def retrieve_latest_frame(self, frame_rec_array:np.recarray) -> np.recarray: 54 | frame_rec_array = self.get_latest_data(frame_rec_array) 55 | return frame_rec_array 56 | 57 | def retrieve_next_frame(self, frame_rec_array:np.recarray) -> np.recarray: 58 | frame_rec_array = self.get_next_data(frame_rec_array) 59 | return frame_rec_array -------------------------------------------------------------------------------- /skellycam-ui/vite.config.flat.txt: -------------------------------------------------------------------------------- 1 | import { rmSync } from 'node:fs' 2 | import path from 'node:path' 3 | import { defineConfig } from 'vite' 4 | import react from '@vitejs/plugin-react' 5 | import electron from 'vite-plugin-electron' 6 | import renderer from 'vite-plugin-electron-renderer' 7 | import pkg from './package.json' 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig(({ command }) => { 11 | rmSync('dist-electron', { recursive: true, force: true }) 12 | 13 | const isServe = command === 'serve' 14 | const isBuild = command === 'build' 15 | const sourcemap = isServe || !!process.env.VSCODE_DEBUG 16 | 17 | return { 18 | resolve: { 19 | alias: { 20 | '@': path.join(__dirname, 'src') 21 | }, 22 | }, 23 | plugins: [ 24 | react(), 25 | electron([ 26 | { 27 | // Main-Process entry file of the Electron App. 28 | entry: 'electron/main/index.ts', 29 | onstart(options) { 30 | if (process.env.VSCODE_DEBUG) { 31 | console.log(/* For `.vscode/.debug.script.mjs` */'[startup] Electron App') 32 | } else { 33 | options.startup() 34 | } 35 | }, 36 | vite: { 37 | build: { 38 | sourcemap, 39 | minify: isBuild, 40 | outDir: 'dist-electron/main', 41 | rollupOptions: { 42 | external: Object.keys('dependencies' in pkg ? pkg.dependencies : {}), 43 | }, 44 | }, 45 | }, 46 | }, 47 | { 48 | entry: 'electron/preload/index.ts', 49 | onstart(options) { 50 | // Notify the Renderer-Process to reload the page when the Preload-Scripts build is complete, 51 | // instead of restarting the entire Electron App. 52 | options.reload() 53 | }, 54 | vite: { 55 | build: { 56 | sourcemap: sourcemap ? 'inline' : undefined, // #332 57 | minify: isBuild, 58 | outDir: 'dist-electron/preload', 59 | rollupOptions: { 60 | external: Object.keys('dependencies' in pkg ? pkg.dependencies : {}), 61 | }, 62 | }, 63 | }, 64 | } 65 | ]), 66 | // Use Node.js API in the Renderer-process 67 | renderer(), 68 | ], 69 | server: process.env.VSCODE_DEBUG && (() => { 70 | const url = new URL(pkg.debug.env.VITE_DEV_SERVER_URL) 71 | return { 72 | host: url.hostname, 73 | port: +url.port, 74 | } 75 | })(), 76 | clearScreen: false, 77 | } 78 | }) 79 | -------------------------------------------------------------------------------- /skellycam/system/diagnostics/find_optimal_chunk_size_for_pipe_transer.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import time 3 | from typing import Tuple 4 | 5 | 6 | def worker(connection: multiprocessing.Pipe, data_size: int) -> None: 7 | data = bytearray(data_size) 8 | connection.send_bytes(data) 9 | connection.shutdown() 10 | 11 | 12 | def measure_time(chunk_size: int, data_size: int) -> float: 13 | parent_conn, child_conn = multiprocessing.Pipe() # duplex=True) 14 | start_time = time.perf_counter_ns() 15 | process = multiprocessing.Process(target=worker, args=(child_conn, data_size)) 16 | process.start() 17 | try: 18 | while True: 19 | size_to_receive = min(chunk_size, data_size) 20 | if size_to_receive == 0: 21 | break 22 | received_bytes = parent_conn.recv_bytes() 23 | data_size -= len(received_bytes) 24 | except (BrokenPipeError, ValueError) as e: 25 | print(f"Failed at chunk size {chunk_size}: {e}") 26 | return float('inf') 27 | process.join() 28 | end_time = time.perf_counter_ns() 29 | return (end_time - start_time) / 1e9 30 | 31 | 32 | def find_optimal_chunk_size(data_size: int) -> Tuple[int, float]: 33 | best_time = float('inf') 34 | best_chunk_size = None 35 | chunk_size = 2 ** 10 # Start with 1 KB 36 | 37 | while chunk_size <= data_size: 38 | # print(f"Measuring time for to send {data_size/1024/1024} MB with chunk size {chunk_size/1024/1024} MB...") 39 | elapsed_time = measure_time(chunk_size, data_size) 40 | 41 | if elapsed_time == float('inf'): # Stop if pipe fails 42 | break 43 | print(f"Chunk Size: {chunk_size / 1024 / 1024:.3} MB, Time: {elapsed_time:.3f} seconds") 44 | if elapsed_time < best_time: 45 | best_time = elapsed_time 46 | best_chunk_size = chunk_size 47 | chunk_size *= 2 # Double the chunk size each iteration 48 | 49 | return best_chunk_size, best_time 50 | 51 | 52 | if __name__ == "__main__": 53 | # 1 GB 54 | data_size = 2 ** 30 55 | print( 56 | f"Measuring optimal chunk size to send {data_size / 1024 / 1024} MB through a multiprocessing.Pipe() connection...") 57 | optimal_chunk_size, optimal_time = find_optimal_chunk_size(data_size) 58 | if optimal_chunk_size: 59 | print(f"\nOptimal Chunk Size: {optimal_chunk_size} bytes, Time: {optimal_time:.6f} seconds") 60 | else: 61 | print("\nNo optimal chunk size found. All attempts failed.") 62 | 63 | # CONCLUSION - Chunk size doesn't seem to matter much? Differences are pretty negligible (on my machine anyway). Also duplex option doesn't seem to make much difference. 64 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/thunks/connect-to-cameras-thunk.ts: -------------------------------------------------------------------------------- 1 | import {createAsyncThunk} from "@reduxjs/toolkit"; 2 | import { 3 | selectConfigsForSelectedCameras, 4 | setError, 5 | setLoading, 6 | updateCameraConfigs 7 | } from "@/store/slices/cameras-slices/camerasSlice"; 8 | import {CameraConfig} from "@/store/slices/cameras-slices/camera-types"; 9 | import {useAppUrls} from "@/hooks/useAppUrls"; 10 | 11 | export const connectToCameras = createAsyncThunk( 12 | 'cameras/connect', 13 | async (_, {dispatch, getState}) => { 14 | const state = getState() as any; 15 | const cameraConfigs = selectConfigsForSelectedCameras(state); 16 | 17 | if (!cameraConfigs || Object.keys(cameraConfigs).length === 0) { 18 | const errorMsg = 'No camera devices selected for connection'; 19 | dispatch(setError(errorMsg)); 20 | throw new Error(errorMsg); 21 | } 22 | 23 | dispatch(setLoading(true)); 24 | 25 | const connectUrl = useAppUrls.getHttpEndpointUrls().createGroup; 26 | 27 | const payload = { 28 | camera_configs: cameraConfigs 29 | }; 30 | 31 | const requestBody = JSON.stringify(payload, null, 2); 32 | try { 33 | console.log(`Connecting to cameras at ${connectUrl} with body:`, requestBody); 34 | const response = await fetch(connectUrl, { 35 | method: 'POST', 36 | headers: { 37 | 'Content-Type': 'application/json', 38 | }, 39 | body: requestBody 40 | }); 41 | 42 | // Parse the response body 43 | const data = await response.json(); 44 | 45 | // Check for different error status codes 46 | if (!response.ok) { 47 | let errorMsg = 'Failed to connect to cameras'; 48 | console.error('Errors:', data.detail); 49 | dispatch(setError(errorMsg)); 50 | throw new Error(errorMsg); 51 | } 52 | 53 | dispatch(setError(null)); 54 | dispatch(updateCameraConfigs(data.camera_configs as Record)); 55 | return data; 56 | } catch (error) { 57 | // Handle network errors and JSON parsing errors 58 | const errorMessage = error instanceof Error 59 | ? `Failed to connect to cameras: ${error.message}` 60 | : 'Failed to connect to cameras: Unknown error'; 61 | 62 | dispatch(setError(errorMessage)); 63 | console.error(errorMessage, error); 64 | throw error; 65 | } finally { 66 | dispatch(setLoading(false)); 67 | } 68 | } 69 | ); 70 | -------------------------------------------------------------------------------- /skellycam/system/diagnostics/numpy_array_copy_duration.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import numpy as np 4 | 5 | # Create a fake 1080p image with random values 6 | fake_image = np.random.randint(0, 256, (1080, 1920, 3), dtype=np.uint8) 7 | 8 | # Array to store durations 9 | copy_durations = np.zeros(int(1e3), dtype=np.int64) 10 | loop_durations = np.zeros(int(1e3), dtype=np.int64) 11 | loop_minus_copy_durations = np.zeros(int(1e3), dtype=np.int64) 12 | 13 | loop_start_time = time.perf_counter_ns() 14 | for i in range(int(1e3)): 15 | start_time = time.perf_counter_ns() 16 | copied_image = fake_image.copy() 17 | end_time = time.perf_counter_ns() 18 | copy_durations[i] = end_time - start_time 19 | loop_durations[i] = end_time - loop_start_time 20 | loop_start_time = end_time 21 | 22 | loop_minus_copy_durations = loop_durations - copy_durations 23 | 24 | # Calculate statistics using numpy 25 | mean_copy_duration_us = np.mean(copy_durations) / 1e3 26 | median_copy_duration_us = np.median(copy_durations) / 1e3 27 | std_copy_duration_us = np.std(copy_durations) / 1e3 28 | mad_copy_duration_us = np.median(np.abs(copy_durations - np.median(copy_durations))) / 1e3 29 | 30 | mean_loop_duration_us = np.mean(loop_durations) / 1e3 31 | median_loop_duration_us = np.median(loop_durations) / 1e3 32 | std_loop_duration_us = np.std(loop_durations) / 1e3 33 | mad_loop_duration_us = np.median(np.abs(loop_durations - np.median(loop_durations))) / 1e3 34 | 35 | mean_loop_minus_copy_duration_us = np.mean(loop_minus_copy_durations) / 1e3 36 | median_loop_minus_copy_duration_us = np.median(loop_minus_copy_durations) / 1e3 37 | std_loop_minus_copy_duration_us = np.std(loop_minus_copy_durations) / 1e3 38 | mad_loop_minus_copy_duration_us = np.median( 39 | np.abs(loop_minus_copy_durations - np.median(loop_minus_copy_durations))) / 1e3 40 | 41 | # Print the results 42 | print(f"Copy duration statistics (us):") 43 | print(f"Mean: {mean_copy_duration_us:.3f}us") 44 | print(f"Median: {median_copy_duration_us:.3f}us") 45 | print(f"Standard Deviation: {std_copy_duration_us:.3f}us") 46 | print(f"Mean Absolute Deviation: {mad_copy_duration_us:.3f}us") 47 | 48 | print(f"\nLoop duration statistics (us):") 49 | print(f"Mean: {mean_loop_duration_us:.3f}us") 50 | print(f"Median: {median_loop_duration_us:.3f}us") 51 | print(f"Standard Deviation: {std_loop_duration_us:.3f}us") 52 | print(f"Mean Absolute Deviation: {mad_loop_duration_us:.3f}us") 53 | 54 | print(f"\nLoop duration minus Copy duration statistics (us):") 55 | print(f"Mean: {mean_loop_minus_copy_duration_us:.3f}us") 56 | print(f"Median: {median_loop_minus_copy_duration_us:.3f}us") 57 | print(f"Standard Deviation: {std_loop_minus_copy_duration_us:.3f}us") 58 | print(f"Mean Absolute Deviation: {mad_loop_minus_copy_duration_us:.3f}us") 59 | -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/log-records/log-records-slice.ts: -------------------------------------------------------------------------------- 1 | // log-records-slice.ts 2 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 3 | import { LogRecord } from './logs-types'; 4 | 5 | interface LogRecordsState { 6 | entries: LogRecord[]; 7 | maxEntries: number; 8 | isPaused: boolean; 9 | filter: { 10 | levels: string[]; 11 | searchText: string; 12 | }; 13 | } 14 | 15 | const initialState: LogRecordsState = { 16 | entries: [], 17 | maxEntries: 1000, 18 | isPaused: false, 19 | filter: { 20 | levels: [], 21 | searchText: '', 22 | }, 23 | }; 24 | 25 | export const logRecordsSlice = createSlice({ 26 | name: 'logs', 27 | initialState, 28 | reducers: { 29 | logAdded: (state, action: PayloadAction) => { 30 | if (state.isPaused) return; 31 | 32 | state.entries.push(action.payload); 33 | 34 | // Keep only the last maxEntries 35 | if (state.entries.length > state.maxEntries) { 36 | state.entries = state.entries.slice(-state.maxEntries); 37 | } 38 | }, 39 | 40 | logsAdded: (state, action: PayloadAction) => { 41 | if (state.isPaused) return; 42 | 43 | state.entries.push(...action.payload); 44 | 45 | // Keep only the last maxEntries 46 | if (state.entries.length > state.maxEntries) { 47 | state.entries = state.entries.slice(-state.maxEntries); 48 | } 49 | }, 50 | 51 | logsCleared: (state) => { 52 | state.entries = []; 53 | }, 54 | 55 | logsPaused: (state, action: PayloadAction) => { 56 | state.isPaused = action.payload; 57 | }, 58 | 59 | logsFiltered: (state, action: PayloadAction<{ levels?: string[]; searchText?: string }>) => { 60 | if (action.payload.levels !== undefined) { 61 | state.filter.levels = action.payload.levels; 62 | } 63 | if (action.payload.searchText !== undefined) { 64 | state.filter.searchText = action.payload.searchText; 65 | } 66 | }, 67 | 68 | maxEntriesUpdated: (state, action: PayloadAction) => { 69 | state.maxEntries = action.payload; 70 | 71 | // Trim entries if new max is smaller 72 | if (state.entries.length > action.payload) { 73 | state.entries = state.entries.slice(-action.payload); 74 | } 75 | }, 76 | }, 77 | }); 78 | 79 | export const { 80 | logAdded, 81 | logsAdded, 82 | logsCleared, 83 | logsPaused, 84 | logsFiltered, 85 | maxEntriesUpdated 86 | } = logRecordsSlice.actions; 87 | -------------------------------------------------------------------------------- /skellycam/core/camera/opencv/opencv_helpers/check_for_new_config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import cv2 4 | import numpy as np 5 | 6 | from skellycam.core.camera.config.camera_config import CameraConfig 7 | from skellycam.core.camera.opencv.opencv_helpers.opencv_apply_config import apply_camera_configuration 8 | from skellycam.core.camera_group.camera_group_ipc import CameraGroupIPC 9 | from skellycam.core.camera_group.camera_status import CameraStatus 10 | from skellycam.core.ipc.pubsub.pubsub_manager import TopicTypes 11 | from skellycam.core.ipc.pubsub.pubsub_topics import UpdateCamerasSettingsMessage, DeviceExtractedConfigMessage 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | def check_for_new_config(current_config: CameraConfig, 16 | frame_rec_array: np.recarray, 17 | cv2_video_capture: cv2.VideoCapture, 18 | ipc: CameraGroupIPC, 19 | self_status: CameraStatus, 20 | update_camera_settings_subscription) -> tuple[np.recarray, CameraConfig]: 21 | if not update_camera_settings_subscription.empty(): 22 | logger.debug( 23 | f"Camera {frame_rec_array.frame_metadata.camera_config.camera_id[0]} received update_camera_settings_subscription message") 24 | update_message = update_camera_settings_subscription.get() 25 | if not isinstance(update_message, UpdateCamerasSettingsMessage): 26 | raise RuntimeError( 27 | f"Expected UpdateCamerasSettingsMessage for camera {frame_rec_array.frame_metadata.camera_config.camera_id[0]}, " 28 | f"but received {type(update_message)}" 29 | ) 30 | if frame_rec_array.frame_metadata.camera_config.camera_id[0] in update_message.requested_configs: 31 | self_status.updating.value = True 32 | new_config = update_message.requested_configs[frame_rec_array.frame_metadata.camera_config.camera_id[0]] 33 | extracted_config = apply_camera_configuration(cv2_vid_capture=cv2_video_capture, 34 | prior_config=CameraConfig.from_numpy_record_array( 35 | frame_rec_array.frame_metadata.camera_config), 36 | config=new_config, ) 37 | frame_rec_array.frame_metadata.camera_config[0] = extracted_config.to_numpy_record_array() 38 | ipc.pubsub.topics[TopicTypes.EXTRACTED_CONFIG].publish( 39 | DeviceExtractedConfigMessage(extracted_config=extracted_config)) 40 | self_status.updating.value = False 41 | current_config = extracted_config 42 | 43 | return frame_rec_array, current_config 44 | -------------------------------------------------------------------------------- /skellycam/utilities/active_elements_check.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import multiprocessing 4 | import threading 5 | import time 6 | 7 | logger = logging.getLogger(__name__) 8 | from tabulate import tabulate 9 | 10 | def check_active_processes(): 11 | print("\nActive Processes:") 12 | for process in multiprocessing.active_children(): 13 | print(f'Process Name: {process.name}, PID: {process.pid}') 14 | 15 | 16 | def check_active_threads(): 17 | print("\nActive Threads:") 18 | for thread in threading.enumerate(): 19 | print(f'Thread Name: {thread.name}, ID: {thread.ident}') 20 | 21 | 22 | async def check_active_asyncio_tasks(): 23 | print("\nActive Asyncio Tasks:") 24 | for task in asyncio.all_tasks(): 25 | print(f'Task: {task.get_name()}, Done: {task.done()}, Canceled: {task.cancelled()}') 26 | 27 | 28 | 29 | async def gather_all_active_info(): 30 | active_info = [] 31 | main_process = multiprocessing.current_process() 32 | active_info.append({"Type": "Process", "Name": main_process.name, "ID": main_process.pid}) 33 | 34 | # Gather active processes 35 | for process in multiprocessing.active_children(): 36 | active_info.append({"Type": "Process", "Name": process.name, "ID": process.pid}) 37 | active_info.append({"Type": "--", "Name": '--', "ID": '--'}) 38 | # Gather active threads 39 | for thread in threading.enumerate(): 40 | active_info.append({"Type": "Thread", "Name": thread.name, "ID": thread.ident}) 41 | active_info.append({"Type": "--", "Name": '--', "ID": '--'}) 42 | # Gather active asyncio tasks 43 | for task in asyncio.all_tasks(): 44 | if not task.done(): 45 | active_info.append({"Type": "Asyncio Task", "Name": task.get_name(), "ID": "N/A"}) 46 | 47 | return active_info 48 | 49 | async def active_elements_check_loop(global_kill_flag: multiprocessing.Value, context: str): 50 | print("Starting app lifecycle check loop") 51 | process_check_clock_time = 5 52 | counter = 0 53 | active_info = None 54 | 55 | while not global_kill_flag.value: 56 | time.sleep(1) 57 | counter += 1 58 | if counter % process_check_clock_time == 0: 59 | active_info = await gather_all_active_info() 60 | if active_info: 61 | print(f"\n{context} - Active Elements:") 62 | print(tabulate(active_info, headers="keys", tablefmt="pretty")) 63 | else: 64 | print("\nNo active elements.") 65 | 66 | print("FINAL PRINT Active Elements:") 67 | if active_info: 68 | print(tabulate(active_info, headers="keys", tablefmt="pretty")) 69 | else: 70 | print("\nNo active elements.") 71 | logger.info("App lifecycle check loop ended") -------------------------------------------------------------------------------- /skellycam/core/ipc/shared_memory/shared_memory_number.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from skellycam.core.ipc.shared_memory.shared_memory_element import SharedMemoryElement 4 | 5 | 6 | class SharedMemoryNumber(SharedMemoryElement): 7 | 8 | @classmethod 9 | def create(cls, read_only: bool, dtype: np.dtype = np.int64, initial_value: int = -1): 10 | if isinstance(dtype, type): 11 | # Convert type to dtype if a type was passed 12 | dtype = np.dtype(dtype) 13 | 14 | # Create a structured dtype with a single field 'value' 15 | if dtype.names is None: 16 | dtype = np.dtype([('value', dtype)]) 17 | 18 | instance = super().create(dtype=dtype, read_only=read_only) 19 | # Assign the value to the 'value' field of the record 20 | instance.value = initial_value 21 | return instance 22 | 23 | @property 24 | def value(self) -> int: 25 | # Extract the value from the 'value' field of the record 26 | return int(self.retrieve_data().value) 27 | 28 | @value.setter 29 | def value(self, value: int): 30 | # Create a structured array with a single field 'value' and shape (1,) 31 | data = np.recarray(shape=(1,), dtype=self.dtype) 32 | data.value = value 33 | self.put_data(data) 34 | 35 | if __name__ == "__main__": 36 | print("Creating original SharedMemoryNumber...") 37 | original = SharedMemoryNumber.create(initial_value=42, read_only=False) 38 | 39 | print(f"Original value: {original.value}") 40 | 41 | # Create DTO for sharing with another process 42 | dto = original.to_dto() 43 | print(f"DTO: {dto}") 44 | 45 | # Simulate another process by recreating from DTO 46 | print("Recreating from DTO (simulating another process)...") 47 | copy = SharedMemoryNumber.recreate(dto=dto, read_only=True) 48 | 49 | # Get value from the copy 50 | retrieved_value = copy.value 51 | print(f"Retrieved value: {retrieved_value}") 52 | 53 | # Verify value is the same 54 | is_equal = original.value == retrieved_value 55 | print(f"Value verification: {'Success' if is_equal else 'Failed'}") 56 | 57 | # Test modifying the value 58 | print("Modifying value...") 59 | original.value = 100 60 | print(f"Original after modification: {original.value}") 61 | print(f"Copy after original was modified: {copy.value}") 62 | 63 | # Test valid flag 64 | print(f"Valid flag: {copy.valid}") 65 | original.valid = False 66 | print(f"Valid flag after setting to False: {copy.valid}") 67 | 68 | # Clean up 69 | print("Cleaning up...") 70 | copy.close() 71 | print("Closed copy.") 72 | 73 | # Clean up original 74 | original.unlink_and_close() 75 | print("Closed and unlinked original.") 76 | 77 | print("Test completed.") -------------------------------------------------------------------------------- /skellycam-ui/src/store/slices/videos/videos-thunks.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from '@reduxjs/toolkit'; 2 | import {electronIpc} from "@/services/electron-ipc/electron-ipc"; 3 | import {VideoFile} from "@/store"; 4 | 5 | const VIDEO_EXTENSIONS = ['.mp4', '.avi', '.mov', '.mkv', '.webm']; 6 | 7 | interface FolderEntry { 8 | name: string; 9 | path: string; 10 | isDirectory: boolean; 11 | isFile: boolean; 12 | size: number; 13 | modified: string | number | Date; 14 | } 15 | 16 | export const selectVideoLoadFolder = createAsyncThunk< 17 | { folder: string; files: VideoFile[] } | null 18 | >('videos/selectFolder', async () => { 19 | if (!electronIpc) { 20 | throw new Error('Electron API not available'); 21 | } 22 | 23 | const selectedFolder = await electronIpc.fileSystem.selectDirectory.mutate(); 24 | if (!selectedFolder) return null; 25 | 26 | const entries: FolderEntry[] = await electronIpc.fileSystem.getFolderContents.query({ 27 | path: selectedFolder, 28 | }); 29 | 30 | const videoFiles = (entries ?? []) 31 | .filter( 32 | (item) => 33 | item.isFile && 34 | VIDEO_EXTENSIONS.some((ext) => item.name.toLowerCase().endsWith(ext)) 35 | ) 36 | .map((file) => ({ 37 | name: file.name, 38 | path: file.path, 39 | size: file.size, 40 | })); 41 | 42 | return { folder: selectedFolder, files: videoFiles }; 43 | }); 44 | 45 | export const loadVideos = createAsyncThunk< 46 | { success: boolean }, 47 | { folder: string; files: VideoFile[] } 48 | >('videos/load', async ({ folder }) => { 49 | if (!electronIpc) { 50 | throw new Error('Electron API not available'); 51 | } 52 | 53 | const success = await electronIpc.fileSystem.openFolder.mutate({ path: folder }); 54 | if (!success) { 55 | throw new Error('Failed to open folder'); 56 | } 57 | return { success: true }; 58 | }); 59 | 60 | export const openVideoFile = createAsyncThunk< 61 | { success: boolean; error?: Error }, 62 | string 63 | >('videos/openFile', async (filePath) => { 64 | if (!electronIpc) { 65 | return { success: false, error: new Error('Electron API not available') }; 66 | } 67 | 68 | try { 69 | const idx = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')); 70 | const folderPath = idx >= 0 ? filePath.substring(0, idx) : filePath; 71 | await electronIpc.fileSystem.openFolder.mutate({ path: folderPath }); 72 | return { success: true }; 73 | } catch (error) { 74 | console.error('Failed to open video file:', error); 75 | return { 76 | success: false, 77 | error: error instanceof Error ? error : new Error('Unknown error'), 78 | }; 79 | } 80 | }); 81 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/camera-views/CameraViewsGrid.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box } from "@mui/material"; 3 | import { CameraView } from "./CameraView"; 4 | import { useServer } from "@/services/server/ServerContextProvider"; 5 | 6 | interface CameraSettings { 7 | columns: number | null; 8 | } 9 | 10 | interface CameraViewsGridProps { 11 | settings?: CameraSettings; 12 | } 13 | 14 | export const CameraViewsGrid: React.FC = ({ 15 | settings 16 | }) => { 17 | const { connectedCameraIds } = useServer(); 18 | 19 | const getColumns = (total: number): number => { 20 | // If manual columns setting is provided, use it 21 | if (settings?.columns !== null && settings?.columns !== undefined) { 22 | return settings.columns; 23 | } 24 | 25 | // Otherwise, auto-calculate 26 | if (total <= 1) return 1; 27 | if (total <= 4) return 2; 28 | if (total <= 9) return 3; 29 | return 4; 30 | }; 31 | 32 | const columns = getColumns(connectedCameraIds.length); 33 | 34 | if (connectedCameraIds.length === 0) { 35 | return ( 36 | 47 | 48 | No cameras connected 49 | 50 | Waiting for camera streams... 51 | 52 | 53 | 54 | ); 55 | } 56 | 57 | return ( 58 | 68 | {connectedCameraIds.map(cameraId => ( 69 | 76 | 77 | 78 | ))} 79 | 80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /skellycam-ui/src/components/update/Modal/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {ReactNode} from 'react' 2 | import {createPortal} from 'react-dom' 3 | import './modal.css' 4 | 5 | const ModalTemplate: React.FC void 11 | onOk?: () => void 12 | width?: number 13 | }>> = props => { 14 | const { 15 | title, 16 | children, 17 | footer, 18 | cancelText = 'Cancel', 19 | okText = 'OK', 20 | onCancel, 21 | onOk, 22 | width = 530, 23 | } = props 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | {title} 32 | 36 | 40 | 43 | 44 | 45 | 46 | 47 | {children} 48 | {typeof footer !== 'undefined' ? ( 49 | 50 | {cancelText} 51 | {okText} 52 | 53 | ) : footer} 54 | 55 | 56 | 57 | ) 58 | } 59 | 60 | const Modal = (props: Parameters[0] & { open: boolean }) => { 61 | const {open, ...omit} = props 62 | 63 | return createPortal( 64 | open ? ModalTemplate(omit) : null, 65 | document.body, 66 | ) 67 | } 68 | 69 | export default Modal 70 | -------------------------------------------------------------------------------- /skellycam-ui/src/services/server/server-helpers/frame-processor/frame-processor.ts: -------------------------------------------------------------------------------- 1 | // frame-processor.ts 2 | import { parseMultiFramePayload, ParsedFrame } from "@/services/server/server-helpers/frame-processor/binary-frame-parser"; 3 | 4 | export interface FrameData { 5 | cameraId: string; 6 | cameraIndex: number; 7 | frameNumber: number; 8 | width: number; 9 | height: number; 10 | colorChannels: number; 11 | bitmap: ImageBitmap; 12 | } 13 | 14 | export interface ProcessedFrameResult { 15 | frames: FrameData[]; 16 | cameraIds: Set; 17 | frameNumbers: Set; 18 | } 19 | 20 | export class FrameProcessor { 21 | private lastFrameTime: Map = new Map(); 22 | private currentFps: Map = new Map(); 23 | 24 | public async processFramePayload(data: ArrayBuffer): Promise { 25 | try { 26 | const parsedFrames = await parseMultiFramePayload(data); 27 | if (!parsedFrames) { 28 | console.warn('Failed to parse frame payload'); 29 | return null; 30 | } 31 | 32 | const cameraIds = new Set(); 33 | const frameNumbers = new Set(); 34 | 35 | // Convert ParsedFrame to FrameData (they have the same structure now) 36 | const frames: FrameData[] = parsedFrames.map((frame: ParsedFrame) => ({ 37 | cameraId: frame.cameraId, 38 | cameraIndex: frame.cameraIndex, 39 | frameNumber: frame.frameNumber, 40 | width: frame.width, 41 | height: frame.height, 42 | colorChannels: frame.colorChannels, 43 | bitmap: frame.bitmap 44 | })); 45 | 46 | for (const frame of frames) { 47 | cameraIds.add(frame.cameraId); 48 | frameNumbers.add(frame.frameNumber); 49 | 50 | // Track frame timing and calculate FPS 51 | const now = performance.now(); 52 | const lastTime = this.lastFrameTime.get(frame.cameraId); 53 | if (lastTime) { 54 | const fps = 1000 / (now - lastTime); 55 | this.currentFps.set(frame.cameraId, fps); 56 | } 57 | this.lastFrameTime.set(frame.cameraId, now); 58 | } 59 | 60 | return { frames, cameraIds, frameNumbers }; 61 | } catch (error) { 62 | console.error('Error processing frame payload:', error); 63 | throw new Error(`Frame processing failed: ${error}`); 64 | } 65 | } 66 | 67 | public getFps(cameraId: string): number | null { 68 | return this.currentFps.get(cameraId) ?? null; 69 | } 70 | 71 | public reset(): void { 72 | this.lastFrameTime.clear(); 73 | this.currentFps.clear(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /skellycam/core/ipc/pubsub/pubsub_abcs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC 3 | from multiprocessing.process import parent_process 4 | from typing import Type 5 | 6 | from pydantic import BaseModel, Field, ConfigDict 7 | 8 | from skellycam.core.types.type_overloads import TopicSubscriptionQueue 9 | from skellycam.utilities.wait_functions import wait_100ms 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | class TopicMessageABC(BaseModel, ABC): 14 | """ 15 | Base class for messages sent through the PubSub system. 16 | All messages should inherit from this class. 17 | """ 18 | model_config = ConfigDict(arbitrary_types_allowed=True) 19 | 20 | 21 | class PubSubTopicABC(BaseModel, ABC): 22 | subscriptions: list[TopicSubscriptionQueue] = Field(default_factory=list) 23 | message_type: Type[TopicMessageABC] = Field(default_factory=TopicMessageABC) 24 | 25 | model_config = ConfigDict( 26 | arbitrary_types_allowed=True, 27 | ) 28 | 29 | 30 | def get_subscription(self) -> TopicSubscriptionQueue: 31 | """ 32 | Subscribe a queue to this topic. 33 | """ 34 | if parent_process() is not None: 35 | raise RuntimeError("Subscriptions must be created in the main process and passed to children") 36 | sub = TopicSubscriptionQueue() 37 | self.subscriptions.append(sub) 38 | return sub 39 | 40 | 41 | def publish(self, message:TopicMessageABC, overwrite:bool=False, print_log:bool=True): 42 | """ 43 | Publish a message to all subscribers of this topic. 44 | """ 45 | if not isinstance(message, self.message_type): 46 | raise TypeError(f"Expected {self.message_type} but got {type(message)}") 47 | if len(self.subscriptions) == 0: 48 | logger.warning(f"Publishing message of type {self.message_type} with no subscribers, message will be lost") 49 | return 50 | if print_log: 51 | logger.trace(f"Publishing message of type {self.message_type} to {len(self.subscriptions)} subscribers") 52 | for sub in self.subscriptions: 53 | if overwrite: 54 | overwrote = 0 55 | while not sub.empty(): 56 | sub.get() 57 | overwrote += 1 58 | if overwrote > 0 and print_log: 59 | logger.trace(f"Overwrote {overwrote} messages in subscription queue {sub}") 60 | sub.put(message) 61 | def close(self): 62 | """ 63 | Close all subscriptions for this topic. 64 | """ 65 | logger.debug(f"Closing PubSubTopicABC {self.__class__.__name__} with {len(self.subscriptions)} subscriptions") 66 | for sub in self.subscriptions: 67 | sub.close() 68 | wait_100ms() 69 | self.subscriptions.clear() 70 | logger.debug(f"Closed PubSubTopicABC {self.__class__.__name__}") 71 | --------------------------------------------------------------------------------