Phase 5: Web-Bedienoberfläche nach §17 (React+Vite, ADR-0006)
- ADR-0006: React 18/19 mit Vite im statischen SPA-Modus - Workspace §17.2: Statusleiste 32px, einklappbare Werkzeugleiste, Layer-Tabelle, Inspector ~30% mit Divider-Resize, umschaltbare rechte Seitenleiste, Fußleiste mit Setup/Live-Lock - Layer-Tabelle §17.3: alle 10 Pflichtspalten, Zeilen 30px, 7 unterscheidbare Zustände (Glyph+Farbe+Text, nie nur Farbe), Pfeiltasten-Navigation, Opacity-Fader mit echtem parameter.set - Inspector §17.4: 7 Tabs, Zahlenfelder mit Tippen/Ziehen/Pfeiltasten, Release-Befehl, Live-Lock sperrt Struktur - Design-Tokens §17.6: alle als CSS-Variablen (Flächen, Text, Akzent-Blau, Zustandsfarben, 4px-Raster, 2-4px Radien, Monospace tabular-nums, 100-180ms) - WebSocket: Snapshot bei Connect, Deltas danach, Reconnect 1s/2s/4s max 30s; Browser-Neustart stoppt Output nicht (§3.2) - REST: health/identity/cluster-nodes/parameters/diagnostics, Commands mit expected_revision + Revert bei 409 - Kein GO-Button, keine Standby-Anzeige, keine Cue-Carts (§17.1) - Build: tsc strict + vite, 27 Module, 262KB JS gzip 80KB - Python-Suite 549 grün unberührt
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>HMS MediaEngine</title>
|
||||||
|
<style>
|
||||||
|
/* Vollbild ohne Scroll auf dunklem Anthrazit (§17.2, §17.6). */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0d0f12;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "hms-mediaengine-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+730
@@ -0,0 +1,730 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
react:
|
||||||
|
specifier: ^19.3.0
|
||||||
|
version: 19.3.0
|
||||||
|
react-dom:
|
||||||
|
specifier: ^19.3.0
|
||||||
|
version: 19.3.0(react@19.3.0)
|
||||||
|
devDependencies:
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^19.3.0
|
||||||
|
version: 19.3.0
|
||||||
|
'@types/react-dom':
|
||||||
|
specifier: ^19.3.0
|
||||||
|
version: 19.3.0(@types/react@19.3.0)
|
||||||
|
'@vitejs/plugin-react':
|
||||||
|
specifier: ^6.1.1
|
||||||
|
version: 6.1.1(vite@8.2.2)
|
||||||
|
typescript:
|
||||||
|
specifier: ^7.0.2
|
||||||
|
version: 7.0.2
|
||||||
|
vite:
|
||||||
|
specifier: ^8.2.2
|
||||||
|
version: 8.2.2
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@oxc-project/types@0.149.0':
|
||||||
|
resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==}
|
||||||
|
|
||||||
|
'@rolldown/binding-android-arm-eabi@1.2.8':
|
||||||
|
resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@rolldown/binding-android-arm64@1.2.8':
|
||||||
|
resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@rolldown/binding-darwin-arm64@1.2.8':
|
||||||
|
resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rolldown/binding-darwin-x64@1.2.8':
|
||||||
|
resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rolldown/binding-freebsd-x64@1.2.8':
|
||||||
|
resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm-gnueabihf@1.2.8':
|
||||||
|
resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm64-gnu@1.2.8':
|
||||||
|
resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm64-musl@1.2.8':
|
||||||
|
resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-ppc64-gnu@1.2.8':
|
||||||
|
resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-s390x-gnu@1.2.8':
|
||||||
|
resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-x64-gnu@1.2.8':
|
||||||
|
resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-x64-musl@1.2.8':
|
||||||
|
resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@rolldown/binding-openharmony-arm64@1.2.8':
|
||||||
|
resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@rolldown/binding-win32-arm64-msvc@1.2.8':
|
||||||
|
resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rolldown/binding-win32-x64-msvc@1.2.8':
|
||||||
|
resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rolldown/pluginutils@1.0.1':
|
||||||
|
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||||
|
|
||||||
|
'@types/react-dom@19.3.0':
|
||||||
|
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': ^19.3.0
|
||||||
|
|
||||||
|
'@types/react@19.3.0':
|
||||||
|
resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
|
||||||
|
|
||||||
|
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [aix]
|
||||||
|
|
||||||
|
'@typescript/typescript-darwin-arm64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@typescript/typescript-darwin-x64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@typescript/typescript-freebsd-arm64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@typescript/typescript-freebsd-x64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-arm64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-arm@7.0.2':
|
||||||
|
resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-loong64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-mips64el@7.0.2':
|
||||||
|
resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-ppc64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-riscv64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-s390x@7.0.2':
|
||||||
|
resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-x64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@typescript/typescript-netbsd-arm64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@typescript/typescript-netbsd-x64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@typescript/typescript-openbsd-arm64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@typescript/typescript-openbsd-x64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@typescript/typescript-sunos-x64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
|
||||||
|
'@typescript/typescript-win32-arm64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@typescript/typescript-win32-x64@7.0.2':
|
||||||
|
resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@vitejs/plugin-react@6.1.1':
|
||||||
|
resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
peerDependencies:
|
||||||
|
'@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
|
||||||
|
babel-plugin-react-compiler: ^1.0.0
|
||||||
|
oxc-transform-react: ^0.145.0
|
||||||
|
vite: ^8.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@rolldown/plugin-babel':
|
||||||
|
optional: true
|
||||||
|
babel-plugin-react-compiler:
|
||||||
|
optional: true
|
||||||
|
oxc-transform-react:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
csstype@3.2.3:
|
||||||
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
fdir@6.5.0:
|
||||||
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
picomatch: ^3 || ^4
|
||||||
|
peerDependenciesMeta:
|
||||||
|
picomatch:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
lightningcss-android-arm64@1.33.0:
|
||||||
|
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
lightningcss-darwin-arm64@1.33.0:
|
||||||
|
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
lightningcss-darwin-x64@1.33.0:
|
||||||
|
resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
lightningcss-freebsd-x64@1.33.0:
|
||||||
|
resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
lightningcss-linux-arm-gnueabihf@1.33.0:
|
||||||
|
resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-gnu@1.33.0:
|
||||||
|
resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-musl@1.33.0:
|
||||||
|
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
lightningcss-linux-x64-gnu@1.33.0:
|
||||||
|
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
lightningcss-linux-x64-musl@1.33.0:
|
||||||
|
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
lightningcss-win32-arm64-msvc@1.33.0:
|
||||||
|
resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
lightningcss-win32-x64-msvc@1.33.0:
|
||||||
|
resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
lightningcss@1.33.0:
|
||||||
|
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
|
nanoid@3.3.18:
|
||||||
|
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||||
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
picocolors@1.1.1:
|
||||||
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
picomatch@4.0.7:
|
||||||
|
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
postcss@8.5.28:
|
||||||
|
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||||
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
react-dom@19.3.0:
|
||||||
|
resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^19.3.0
|
||||||
|
|
||||||
|
react@19.3.0:
|
||||||
|
resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
rolldown@1.2.8:
|
||||||
|
resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
scheduler@0.28.0:
|
||||||
|
resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
|
||||||
|
|
||||||
|
source-map-js@1.2.1:
|
||||||
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
tinyglobby@0.2.17:
|
||||||
|
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
typescript@7.0.2:
|
||||||
|
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
|
||||||
|
engines: {node: '>=16.20.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
vite@8.2.2:
|
||||||
|
resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': ^20.19.0 || >=22.12.0
|
||||||
|
'@vitejs/devtools': ^0.4.0 || ^0.5.0
|
||||||
|
esbuild: ^0.27.0 || ^0.28.0
|
||||||
|
jiti: '>=1.21.0'
|
||||||
|
less: ^4.0.0
|
||||||
|
sass: ^1.70.0
|
||||||
|
sass-embedded: ^1.70.0
|
||||||
|
stylus: '>=0.54.8'
|
||||||
|
sugarss: ^5.0.0
|
||||||
|
terser: ^5.16.0
|
||||||
|
tsx: ^4.8.1
|
||||||
|
yaml: ^2.4.2
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
'@vitejs/devtools':
|
||||||
|
optional: true
|
||||||
|
esbuild:
|
||||||
|
optional: true
|
||||||
|
jiti:
|
||||||
|
optional: true
|
||||||
|
less:
|
||||||
|
optional: true
|
||||||
|
sass:
|
||||||
|
optional: true
|
||||||
|
sass-embedded:
|
||||||
|
optional: true
|
||||||
|
stylus:
|
||||||
|
optional: true
|
||||||
|
sugarss:
|
||||||
|
optional: true
|
||||||
|
terser:
|
||||||
|
optional: true
|
||||||
|
tsx:
|
||||||
|
optional: true
|
||||||
|
yaml:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@oxc-project/types@0.149.0': {}
|
||||||
|
|
||||||
|
'@rolldown/binding-android-arm-eabi@1.2.8':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-android-arm64@1.2.8':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-darwin-arm64@1.2.8':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-darwin-x64@1.2.8':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-freebsd-x64@1.2.8':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm-gnueabihf@1.2.8':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm64-gnu@1.2.8':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm64-musl@1.2.8':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-ppc64-gnu@1.2.8':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-s390x-gnu@1.2.8':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-x64-gnu@1.2.8':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-x64-musl@1.2.8':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-openharmony-arm64@1.2.8':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-win32-arm64-msvc@1.2.8':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-win32-x64-msvc@1.2.8':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/pluginutils@1.0.1': {}
|
||||||
|
|
||||||
|
'@types/react-dom@19.3.0(@types/react@19.3.0)':
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.3.0
|
||||||
|
|
||||||
|
'@types/react@19.3.0':
|
||||||
|
dependencies:
|
||||||
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-darwin-arm64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-darwin-x64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-freebsd-arm64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-freebsd-x64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-arm64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-arm@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-loong64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-mips64el@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-ppc64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-riscv64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-s390x@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-linux-x64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-netbsd-arm64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-netbsd-x64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-openbsd-arm64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-openbsd-x64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-sunos-x64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-win32-arm64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/typescript-win32-x64@7.0.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@vitejs/plugin-react@6.1.1(vite@8.2.2)':
|
||||||
|
dependencies:
|
||||||
|
'@rolldown/pluginutils': 1.0.1
|
||||||
|
vite: 8.2.2
|
||||||
|
|
||||||
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
|
fdir@6.5.0(picomatch@4.0.7):
|
||||||
|
optionalDependencies:
|
||||||
|
picomatch: 4.0.7
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-android-arm64@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-darwin-arm64@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-darwin-x64@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-freebsd-x64@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-arm-gnueabihf@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-gnu@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-musl@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-x64-gnu@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-x64-musl@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-win32-arm64-msvc@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-win32-x64-msvc@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss@1.33.0:
|
||||||
|
dependencies:
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
optionalDependencies:
|
||||||
|
lightningcss-android-arm64: 1.33.0
|
||||||
|
lightningcss-darwin-arm64: 1.33.0
|
||||||
|
lightningcss-darwin-x64: 1.33.0
|
||||||
|
lightningcss-freebsd-x64: 1.33.0
|
||||||
|
lightningcss-linux-arm-gnueabihf: 1.33.0
|
||||||
|
lightningcss-linux-arm64-gnu: 1.33.0
|
||||||
|
lightningcss-linux-arm64-musl: 1.33.0
|
||||||
|
lightningcss-linux-x64-gnu: 1.33.0
|
||||||
|
lightningcss-linux-x64-musl: 1.33.0
|
||||||
|
lightningcss-win32-arm64-msvc: 1.33.0
|
||||||
|
lightningcss-win32-x64-msvc: 1.33.0
|
||||||
|
|
||||||
|
nanoid@3.3.18: {}
|
||||||
|
|
||||||
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
|
picomatch@4.0.7: {}
|
||||||
|
|
||||||
|
postcss@8.5.28:
|
||||||
|
dependencies:
|
||||||
|
nanoid: 3.3.18
|
||||||
|
picocolors: 1.1.1
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
react-dom@19.3.0(react@19.3.0):
|
||||||
|
dependencies:
|
||||||
|
react: 19.3.0
|
||||||
|
scheduler: 0.28.0
|
||||||
|
|
||||||
|
react@19.3.0: {}
|
||||||
|
|
||||||
|
rolldown@1.2.8:
|
||||||
|
dependencies:
|
||||||
|
'@oxc-project/types': 0.149.0
|
||||||
|
'@rolldown/pluginutils': 1.0.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@rolldown/binding-android-arm-eabi': 1.2.8
|
||||||
|
'@rolldown/binding-android-arm64': 1.2.8
|
||||||
|
'@rolldown/binding-darwin-arm64': 1.2.8
|
||||||
|
'@rolldown/binding-darwin-x64': 1.2.8
|
||||||
|
'@rolldown/binding-freebsd-x64': 1.2.8
|
||||||
|
'@rolldown/binding-linux-arm-gnueabihf': 1.2.8
|
||||||
|
'@rolldown/binding-linux-arm64-gnu': 1.2.8
|
||||||
|
'@rolldown/binding-linux-arm64-musl': 1.2.8
|
||||||
|
'@rolldown/binding-linux-ppc64-gnu': 1.2.8
|
||||||
|
'@rolldown/binding-linux-s390x-gnu': 1.2.8
|
||||||
|
'@rolldown/binding-linux-x64-gnu': 1.2.8
|
||||||
|
'@rolldown/binding-linux-x64-musl': 1.2.8
|
||||||
|
'@rolldown/binding-openharmony-arm64': 1.2.8
|
||||||
|
'@rolldown/binding-win32-arm64-msvc': 1.2.8
|
||||||
|
'@rolldown/binding-win32-x64-msvc': 1.2.8
|
||||||
|
|
||||||
|
scheduler@0.28.0: {}
|
||||||
|
|
||||||
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
tinyglobby@0.2.17:
|
||||||
|
dependencies:
|
||||||
|
fdir: 6.5.0(picomatch@4.0.7)
|
||||||
|
picomatch: 4.0.7
|
||||||
|
|
||||||
|
typescript@7.0.2:
|
||||||
|
optionalDependencies:
|
||||||
|
'@typescript/typescript-aix-ppc64': 7.0.2
|
||||||
|
'@typescript/typescript-darwin-arm64': 7.0.2
|
||||||
|
'@typescript/typescript-darwin-x64': 7.0.2
|
||||||
|
'@typescript/typescript-freebsd-arm64': 7.0.2
|
||||||
|
'@typescript/typescript-freebsd-x64': 7.0.2
|
||||||
|
'@typescript/typescript-linux-arm': 7.0.2
|
||||||
|
'@typescript/typescript-linux-arm64': 7.0.2
|
||||||
|
'@typescript/typescript-linux-loong64': 7.0.2
|
||||||
|
'@typescript/typescript-linux-mips64el': 7.0.2
|
||||||
|
'@typescript/typescript-linux-ppc64': 7.0.2
|
||||||
|
'@typescript/typescript-linux-riscv64': 7.0.2
|
||||||
|
'@typescript/typescript-linux-s390x': 7.0.2
|
||||||
|
'@typescript/typescript-linux-x64': 7.0.2
|
||||||
|
'@typescript/typescript-netbsd-arm64': 7.0.2
|
||||||
|
'@typescript/typescript-netbsd-x64': 7.0.2
|
||||||
|
'@typescript/typescript-openbsd-arm64': 7.0.2
|
||||||
|
'@typescript/typescript-openbsd-x64': 7.0.2
|
||||||
|
'@typescript/typescript-sunos-x64': 7.0.2
|
||||||
|
'@typescript/typescript-win32-arm64': 7.0.2
|
||||||
|
'@typescript/typescript-win32-x64': 7.0.2
|
||||||
|
|
||||||
|
vite@8.2.2:
|
||||||
|
dependencies:
|
||||||
|
lightningcss: 1.33.0
|
||||||
|
picomatch: 4.0.7
|
||||||
|
postcss: 8.5.28
|
||||||
|
rolldown: 1.2.8
|
||||||
|
tinyglobby: 0.2.17
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
/**
|
||||||
|
* HMS MediaEngine – MVP-Workspace (§17.2).
|
||||||
|
*
|
||||||
|
* CSS-Grid: obere Statusleiste (32 px), Hauptbereich mit linker
|
||||||
|
* Werkzeugleiste (48 px, einklappbar), mittlerer Arbeitsbereich
|
||||||
|
* (Layer-Tabelle, §17.3), unterem Inspector (~30 %, skalier- und
|
||||||
|
* einklappbar, §17.4) und rechter, umschaltbarer Seitenleiste (§17.5)
|
||||||
|
* sowie Fußleiste (24 px) mit Setup/Live-Lock und Panel-Schaltern.
|
||||||
|
*
|
||||||
|
* Datenfluss: WebSocket liefert Snapshot + Deltas (§17.9); REST-Endpoints
|
||||||
|
* werden gepollt; Opacity-Commits laufen als parameter.set mit
|
||||||
|
* expected_revision über die Engine (§10.2, §23.2) – der Server bleibt
|
||||||
|
* autoritativ, ein Browser-Neustart stoppt den Output nicht (§3.2).
|
||||||
|
* Kein GO-Button, keine Standby-Anzeige, keine Cue-Logik (§17.1).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { ComponentType, PointerEvent as ReactPointerEvent } from 'react';
|
||||||
|
import type {
|
||||||
|
AlertItem,
|
||||||
|
AlertSeverity,
|
||||||
|
ClusterNode,
|
||||||
|
DiagnosticsResponse,
|
||||||
|
FxSlot,
|
||||||
|
HealthResponse,
|
||||||
|
IdentityResponse,
|
||||||
|
InspectorTab,
|
||||||
|
Layer,
|
||||||
|
OpMode,
|
||||||
|
SidebarTab,
|
||||||
|
ToolId,
|
||||||
|
} from './types';
|
||||||
|
import { SEED_COMPOSITION_ID, SEED_LAYERS } from './seed';
|
||||||
|
import { useWebSocket } from './hooks/useWebSocket';
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
getClusterNodes,
|
||||||
|
getDiagnostics,
|
||||||
|
getHealth,
|
||||||
|
getIdentity,
|
||||||
|
releaseCommand,
|
||||||
|
sendParameterSet,
|
||||||
|
} from './hooks/useApi';
|
||||||
|
import StatusBar, { type StatusToneItem } from './components/StatusBar';
|
||||||
|
import LayerTable from './components/LayerTable';
|
||||||
|
import Inspector, { type InspectorPatch } from './components/Inspector';
|
||||||
|
import RightSidebar from './components/RightSidebar';
|
||||||
|
import type { IconProps } from './icons';
|
||||||
|
import {
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconChevronLeft,
|
||||||
|
IconChevronRight,
|
||||||
|
IconCrossCircle,
|
||||||
|
IconDiagnostics,
|
||||||
|
IconEffects,
|
||||||
|
IconInspectorPanel,
|
||||||
|
IconLock,
|
||||||
|
IconMedia,
|
||||||
|
IconMixer,
|
||||||
|
IconNetwork,
|
||||||
|
IconOutputs,
|
||||||
|
IconPlugins,
|
||||||
|
IconPresets,
|
||||||
|
IconSidebarPanel,
|
||||||
|
IconUnlock,
|
||||||
|
} from './icons';
|
||||||
|
import {
|
||||||
|
clamp,
|
||||||
|
cx,
|
||||||
|
engineToOpacity,
|
||||||
|
errorMessage,
|
||||||
|
formatClock,
|
||||||
|
layerOpacityPath,
|
||||||
|
opacityToEngine,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
const PROJECT_NAME = 'Demo-Show'; // Projekt-API folgt in späterer Phase
|
||||||
|
const INSPECTOR_MIN = 96;
|
||||||
|
const INSPECTOR_MAX = 520;
|
||||||
|
const POLL_MS = 5000;
|
||||||
|
|
||||||
|
interface ToolDef {
|
||||||
|
readonly id: ToolId;
|
||||||
|
readonly label: string;
|
||||||
|
readonly icon: ComponentType<IconProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLS: readonly ToolDef[] = [
|
||||||
|
{ id: 'mixer', label: 'Mixer', icon: IconMixer },
|
||||||
|
{ id: 'media', label: 'Media', icon: IconMedia },
|
||||||
|
{ id: 'effects', label: 'Effekte', icon: IconEffects },
|
||||||
|
{ id: 'presets', label: 'Presets', icon: IconPresets },
|
||||||
|
{ id: 'outputs', label: 'Outputs', icon: IconOutputs },
|
||||||
|
{ id: 'network', label: 'Netzwerk', icon: IconNetwork },
|
||||||
|
{ id: 'plugins', label: 'Plugins', icon: IconPlugins },
|
||||||
|
{ id: 'diagnostics', label: 'Diagnostics', icon: IconDiagnostics },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
/* ---------- Verbindungen ---------- */
|
||||||
|
|
||||||
|
const ws = useWebSocket();
|
||||||
|
|
||||||
|
/* ---------- Workspace-Zustand ---------- */
|
||||||
|
|
||||||
|
const [layers, setLayers] = useState<readonly Layer[]>(SEED_LAYERS);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(
|
||||||
|
SEED_LAYERS.length > 0 ? SEED_LAYERS[0].id : null,
|
||||||
|
);
|
||||||
|
const [cursorId, setCursorId] = useState<string | null>(
|
||||||
|
SEED_LAYERS.length > 0 ? SEED_LAYERS[0].id : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [tool, setTool] = useState<ToolId>('mixer');
|
||||||
|
const [toolbarCollapsed, setToolbarCollapsed] = useState(false);
|
||||||
|
const [inspectorCollapsed, setInspectorCollapsed] = useState(false);
|
||||||
|
const [inspectorHeight, setInspectorHeight] = useState(264);
|
||||||
|
const [inspectorResizing, setInspectorResizing] = useState(false);
|
||||||
|
const [inspectorTab, setInspectorTab] = useState<InspectorTab>('layer');
|
||||||
|
const [sidebarVisible, setSidebarVisible] = useState(true);
|
||||||
|
const [sidebarTab, setSidebarTab] = useState<SidebarTab>('active');
|
||||||
|
const [opMode, setOpMode] = useState<OpMode>('setup');
|
||||||
|
const liveLock = opMode === 'live';
|
||||||
|
|
||||||
|
/* ---------- Meldungen ---------- */
|
||||||
|
|
||||||
|
const [alerts, setAlerts] = useState<readonly AlertItem[]>([]);
|
||||||
|
const alertSeqRef = useRef(0);
|
||||||
|
const pushAlert = useCallback((severity: AlertSeverity, message: string): void => {
|
||||||
|
alertSeqRef.current += 1;
|
||||||
|
setAlerts(prev => [...prev, { id: alertSeqRef.current, timeMs: Date.now(), severity, message }].slice(-100));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ---------- REST-Daten (Control Core, app.py) ---------- */
|
||||||
|
|
||||||
|
const [identity, setIdentity] = useState<IdentityResponse | null>(null);
|
||||||
|
const [nodes, setNodes] = useState<readonly ClusterNode[]>([]);
|
||||||
|
const [selfNodeId, setSelfNodeId] = useState<string | null>(null);
|
||||||
|
const [diagnostics, setDiagnostics] = useState<DiagnosticsResponse | null>(null);
|
||||||
|
const [health, setHealth] = useState<HealthResponse | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
const poll = async (): Promise<void> => {
|
||||||
|
const [identRes, nodesRes, diagRes, healthRes] = await Promise.allSettled([
|
||||||
|
getIdentity(),
|
||||||
|
getClusterNodes(),
|
||||||
|
getDiagnostics(),
|
||||||
|
getHealth(),
|
||||||
|
]);
|
||||||
|
if (disposed) return;
|
||||||
|
if (identRes.status === 'fulfilled') setIdentity(identRes.value);
|
||||||
|
if (nodesRes.status === 'fulfilled') {
|
||||||
|
setNodes(nodesRes.value.nodes);
|
||||||
|
setSelfNodeId(nodesRes.value.self);
|
||||||
|
}
|
||||||
|
if (diagRes.status === 'fulfilled') setDiagnostics(diagRes.value);
|
||||||
|
if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
|
||||||
|
};
|
||||||
|
void poll();
|
||||||
|
const timer = window.setInterval(() => void poll(), POLL_MS);
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ---------- Uhr und UI-FPS (Renderer-Metrik folgt mit IPC) ---------- */
|
||||||
|
|
||||||
|
const [now, setNow] = useState<Date>(() => new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const t = window.setInterval(() => setNow(new Date()), 1000);
|
||||||
|
return () => window.clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [uiFps, setUiFps] = useState<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let raf = 0;
|
||||||
|
let frames = 0;
|
||||||
|
let last = performance.now();
|
||||||
|
const tick = (ts: number): void => {
|
||||||
|
frames += 1;
|
||||||
|
if (ts - last >= 1000) {
|
||||||
|
setUiFps((frames * 1000) / (ts - last));
|
||||||
|
frames = 0;
|
||||||
|
last = ts;
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ---------- WS-Verbindungsübergänge als Meldung ---------- */
|
||||||
|
|
||||||
|
const prevConnectedRef = useRef<boolean | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = prevConnectedRef.current;
|
||||||
|
prevConnectedRef.current = ws.connected;
|
||||||
|
if (prev === null) return; // erster Mount: kein Übergang
|
||||||
|
if (prev && !ws.connected) {
|
||||||
|
pushAlert('warning', 'Verbindung zum Control Core verloren – Reconnect mit Backoff läuft.');
|
||||||
|
} else if (!prev && ws.connected) {
|
||||||
|
pushAlert('info', 'Control Core verbunden – Zustand synchronisiert.');
|
||||||
|
}
|
||||||
|
}, [ws.connected, pushAlert]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pushAlert(
|
||||||
|
'info',
|
||||||
|
'MVP-Workspace: Layer-Struktur ist Demo-Platzhalter (seed.ts); Opacity läuft echt über die Parameter-Engine (§10.2, §11).',
|
||||||
|
);
|
||||||
|
}, [pushAlert]);
|
||||||
|
|
||||||
|
/* ---------- Layer-Zustand + echte Opacity aus der Engine ---------- */
|
||||||
|
|
||||||
|
const mergedLayers = useMemo<readonly Layer[]>(() => {
|
||||||
|
if (Object.keys(ws.values).length === 0) return layers;
|
||||||
|
return layers.map(l => {
|
||||||
|
const path = layerOpacityPath(SEED_COMPOSITION_ID, l.id);
|
||||||
|
const engineValue = ws.values[path];
|
||||||
|
return engineValue === undefined ? l : { ...l, opacity: engineToOpacity(engineValue) };
|
||||||
|
});
|
||||||
|
}, [layers, ws.values]);
|
||||||
|
|
||||||
|
const selectedLayer = useMemo<Layer | null>(
|
||||||
|
() => mergedLayers.find(l => l.id === selectedId) ?? null,
|
||||||
|
[mergedLayers, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---------- Opacity: lokales Feedback + serverbestätigter Commit ---------- */
|
||||||
|
|
||||||
|
const dragOriginRef = useRef<Map<string, number>>(new Map());
|
||||||
|
const lastOpacityCmdRef = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
const handleOpacityLocal = useCallback((id: string, value: number): void => {
|
||||||
|
setLayers(prev =>
|
||||||
|
prev.map(l => {
|
||||||
|
if (l.id !== id) return l;
|
||||||
|
if (!dragOriginRef.current.has(id)) {
|
||||||
|
dragOriginRef.current.set(id, l.opacity);
|
||||||
|
}
|
||||||
|
return { ...l, opacity: value };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpacityCommit = useCallback(
|
||||||
|
async (id: string, value: number): Promise<void> => {
|
||||||
|
const path = layerOpacityPath(SEED_COMPOSITION_ID, id);
|
||||||
|
try {
|
||||||
|
const ack = await sendParameterSet({
|
||||||
|
path,
|
||||||
|
value: opacityToEngine(value),
|
||||||
|
expectedRevision: ws.revision,
|
||||||
|
});
|
||||||
|
lastOpacityCmdRef.current.set(id, ack.command_id);
|
||||||
|
} catch (err) {
|
||||||
|
// Server bleibt autoritativ (§17.9): lokales Feedback zurückrollen.
|
||||||
|
const origin = dragOriginRef.current.get(id);
|
||||||
|
if (origin !== undefined) {
|
||||||
|
setLayers(prev => prev.map(l => (l.id === id ? { ...l, opacity: origin } : l)));
|
||||||
|
}
|
||||||
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
|
pushAlert('warning', 'Revision-Konflikt: ein anderer Client hat inzwischen geändert.');
|
||||||
|
} else {
|
||||||
|
pushAlert('error', `Opacity nicht übernommen: ${errorMessage(err)}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
dragOriginRef.current.delete(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ws.revision, pushAlert],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReleaseOpacity = useCallback(
|
||||||
|
async (layerId: string): Promise<void> => {
|
||||||
|
const commandId = lastOpacityCmdRef.current.get(layerId);
|
||||||
|
if (commandId === undefined) {
|
||||||
|
pushAlert('info', 'Kein Web-Override für die Opacity dieses Layers vorhanden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await releaseCommand(commandId);
|
||||||
|
pushAlert('info', `Release ausgeführt – Server: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
pushAlert('error', `Release fehlgeschlagen: ${errorMessage(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pushAlert],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---------- Umbenennen und Inspector-Patches (lokal bis Layer-API) ---------- */
|
||||||
|
|
||||||
|
const handleRename = useCallback((id: string, name: string): void => {
|
||||||
|
setLayers(prev => prev.map(l => (l.id === id ? { ...l, name } : l)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyPatch = useCallback(
|
||||||
|
(patch: InspectorPatch): void => {
|
||||||
|
if (selectedId === null) return;
|
||||||
|
setLayers(prev =>
|
||||||
|
prev.map((l): Layer => {
|
||||||
|
if (l.id !== selectedId) return l;
|
||||||
|
let next: Layer = l;
|
||||||
|
if (patch.name !== undefined) next = { ...next, name: patch.name };
|
||||||
|
if (patch.blend !== undefined) next = { ...next, blend: patch.blend };
|
||||||
|
if (patch.loopMode !== undefined) next = { ...next, loopMode: patch.loopMode };
|
||||||
|
if (patch.timeDurationSec !== undefined) {
|
||||||
|
next = { ...next, timeDurationSec: Math.max(0, patch.timeDurationSec) };
|
||||||
|
}
|
||||||
|
if (patch.generatorPhase !== undefined) {
|
||||||
|
next = { ...next, generatorPhase: patch.generatorPhase };
|
||||||
|
}
|
||||||
|
if (patch.transformX !== undefined) {
|
||||||
|
next = { ...next, transform: { ...next.transform, x: patch.transformX } };
|
||||||
|
}
|
||||||
|
if (patch.transformY !== undefined) {
|
||||||
|
next = { ...next, transform: { ...next.transform, y: patch.transformY } };
|
||||||
|
}
|
||||||
|
if (patch.transformScale !== undefined) {
|
||||||
|
next = { ...next, transform: { ...next.transform, scale: patch.transformScale } };
|
||||||
|
}
|
||||||
|
if (patch.transformRotation !== undefined) {
|
||||||
|
next = { ...next, transform: { ...next.transform, rotation: patch.transformRotation } };
|
||||||
|
}
|
||||||
|
if (patch.colorBrightness !== undefined) {
|
||||||
|
next = { ...next, color: { ...next.color, brightness: patch.colorBrightness } };
|
||||||
|
}
|
||||||
|
if (patch.colorContrast !== undefined) {
|
||||||
|
next = { ...next, color: { ...next.color, contrast: patch.colorContrast } };
|
||||||
|
}
|
||||||
|
if (patch.colorSaturation !== undefined) {
|
||||||
|
next = { ...next, color: { ...next.color, saturation: patch.colorSaturation } };
|
||||||
|
}
|
||||||
|
if (patch.colorHue !== undefined) {
|
||||||
|
next = { ...next, color: { ...next.color, hue: patch.colorHue } };
|
||||||
|
}
|
||||||
|
if (patch.fxBypass !== undefined) {
|
||||||
|
const [idx, bypass] = patch.fxBypass;
|
||||||
|
if (idx !== null) {
|
||||||
|
const slot = next.fxSlots[idx];
|
||||||
|
if (slot !== null && slot.state !== 'error') {
|
||||||
|
const slots: [FxSlot | null, FxSlot | null] = [next.fxSlots[0], next.fxSlots[1]];
|
||||||
|
slots[idx] = { ...slot, state: bypass ? 'bypassed' : 'active' };
|
||||||
|
next = { ...next, fxSlots: slots };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patch.dmxUniverse !== undefined) {
|
||||||
|
next = { ...next, dmx: { ...next.dmx, universe: patch.dmxUniverse } };
|
||||||
|
}
|
||||||
|
if (patch.dmxAddress !== undefined) {
|
||||||
|
next = { ...next, dmx: { ...next.dmx, address: patch.dmxAddress } };
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---------- Inspector-Resize (Divider, §17.2) ---------- */
|
||||||
|
|
||||||
|
const resizeStartRef = useRef<{ readonly y: number; readonly h: number } | null>(null);
|
||||||
|
|
||||||
|
const handleInspectorResizeStart = useCallback(
|
||||||
|
(e: ReactPointerEvent<HTMLDivElement>): void => {
|
||||||
|
e.preventDefault();
|
||||||
|
resizeStartRef.current = { y: e.clientY, h: inspectorHeight };
|
||||||
|
setInspectorResizing(true);
|
||||||
|
},
|
||||||
|
[inspectorHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inspectorResizing) return;
|
||||||
|
const onMove = (e: PointerEvent): void => {
|
||||||
|
const start = resizeStartRef.current;
|
||||||
|
if (start === null) return;
|
||||||
|
const max = Math.min(INSPECTOR_MAX, Math.max(INSPECTOR_MIN, window.innerHeight - 320));
|
||||||
|
setInspectorHeight(clamp(start.h + (start.y - e.clientY), INSPECTOR_MIN, max));
|
||||||
|
};
|
||||||
|
const onUp = (): void => {
|
||||||
|
resizeStartRef.current = null;
|
||||||
|
setInspectorResizing(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
|
}, [inspectorResizing]);
|
||||||
|
|
||||||
|
/* ---------- Setup/Live-Lock (§17.7) ---------- */
|
||||||
|
|
||||||
|
const toggleOpMode = (): void => {
|
||||||
|
if (opMode === 'setup') {
|
||||||
|
setOpMode('live');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Verlassen von Live ist strukturell und wird bestätigt (§17.7).
|
||||||
|
const leave = window.confirm('Live-Modus verlassen? Strukturänderungen werden wieder freigegeben.');
|
||||||
|
if (leave) setOpMode('setup');
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- Abgeleitete Statusanzeigen ---------- */
|
||||||
|
|
||||||
|
const outputStatus = useMemo<StatusToneItem>(() => {
|
||||||
|
if (diagnostics === null) return { tone: 'muted', label: 'Output —' };
|
||||||
|
if (diagnostics.renderer === 'not_connected') {
|
||||||
|
return { tone: 'warn', label: 'Renderer offline' };
|
||||||
|
}
|
||||||
|
return { tone: 'ok', label: 'Output aktiv' };
|
||||||
|
}, [diagnostics]);
|
||||||
|
|
||||||
|
const artnetStatus = useMemo<StatusToneItem>(() => {
|
||||||
|
if (diagnostics === null) return { tone: 'muted', label: 'Art-Net —' };
|
||||||
|
if (diagnostics.artnet === 'not_started') return { tone: 'muted', label: 'Art-Net aus' };
|
||||||
|
return { tone: 'ok', label: 'Art-Net Empfang' };
|
||||||
|
}, [diagnostics]);
|
||||||
|
|
||||||
|
const syncStatus = useMemo<StatusToneItem>(
|
||||||
|
() =>
|
||||||
|
ws.connected
|
||||||
|
? { tone: 'ok', label: `Sync r${ws.revision ?? '—'}` }
|
||||||
|
: { tone: 'bad', label: 'Getrennt' },
|
||||||
|
[ws.connected, ws.revision],
|
||||||
|
);
|
||||||
|
|
||||||
|
const warnTotal =
|
||||||
|
mergedLayers.filter(l => l.status === 'warning' || l.status === 'out_of_sync').length +
|
||||||
|
alerts.filter(a => a.severity === 'warning').length;
|
||||||
|
const errTotal =
|
||||||
|
mergedLayers.filter(l => l.status === 'error' || l.status === 'offline').length +
|
||||||
|
alerts.filter(a => a.severity === 'error').length;
|
||||||
|
|
||||||
|
const nodeFps = useMemo<ReadonlyMap<string, number>>(() => new Map(), []);
|
||||||
|
const artnetSenders = useMemo<ReadonlyMap<string, string>>(() => new Map(), []);
|
||||||
|
|
||||||
|
/* ---------- Render ---------- */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="workspace">
|
||||||
|
<StatusBar
|
||||||
|
projectName={PROJECT_NAME}
|
||||||
|
nodeName={identity?.display_name ?? '—'}
|
||||||
|
output={outputStatus}
|
||||||
|
quality="Auto"
|
||||||
|
fps={uiFps}
|
||||||
|
artnet={artnetStatus}
|
||||||
|
sync={syncStatus}
|
||||||
|
clock={formatClock(now)}
|
||||||
|
/>
|
||||||
|
<div className="main">
|
||||||
|
<nav className={cx('toolbar', toolbarCollapsed && 'collapsed')} aria-label="Werkzeugleiste">
|
||||||
|
{TOOLS.map(t => {
|
||||||
|
const Icon = t.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
className={cx('tool-btn', tool === t.id && 'active')}
|
||||||
|
title={t.label}
|
||||||
|
aria-label={t.label}
|
||||||
|
aria-pressed={tool === t.id}
|
||||||
|
onClick={() => setTool(t.id)}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<span className="tool-spacer" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="collapse-btn"
|
||||||
|
title={toolbarCollapsed ? 'Werkzeugleiste ausklappen' : 'Werkzeugleiste einklappen'}
|
||||||
|
aria-label={toolbarCollapsed ? 'Werkzeugleiste ausklappen' : 'Werkzeugleiste einklappen'}
|
||||||
|
onClick={() => setToolbarCollapsed(v => !v)}
|
||||||
|
>
|
||||||
|
{toolbarCollapsed ? <IconChevronRight size={14} /> : <IconChevronLeft size={14} />}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<div className="center">
|
||||||
|
<LayerTable
|
||||||
|
layers={mergedLayers}
|
||||||
|
selectedId={selectedId}
|
||||||
|
cursorId={cursorId}
|
||||||
|
liveLock={liveLock}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
onCursor={setCursorId}
|
||||||
|
onRename={handleRename}
|
||||||
|
onOpacityLocal={handleOpacityLocal}
|
||||||
|
onOpacityCommit={(id, value) => {
|
||||||
|
void handleOpacityCommit(id, value);
|
||||||
|
}}
|
||||||
|
onEnterPressed={() => {
|
||||||
|
if (inspectorCollapsed) setInspectorCollapsed(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Inspector
|
||||||
|
layer={selectedLayer}
|
||||||
|
activeTab={inspectorTab}
|
||||||
|
onTabChange={setInspectorTab}
|
||||||
|
collapsed={inspectorCollapsed}
|
||||||
|
height={inspectorHeight}
|
||||||
|
liveLock={liveLock}
|
||||||
|
onToggleCollapse={() => setInspectorCollapsed(v => !v)}
|
||||||
|
onResizeStart={handleInspectorResizeStart}
|
||||||
|
resizing={inspectorResizing}
|
||||||
|
onPatch={applyPatch}
|
||||||
|
onReleaseOpacity={id => {
|
||||||
|
void handleReleaseOpacity(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{sidebarVisible && (
|
||||||
|
<RightSidebar
|
||||||
|
tab={sidebarTab}
|
||||||
|
onTabChange={setSidebarTab}
|
||||||
|
layers={mergedLayers}
|
||||||
|
nodes={nodes}
|
||||||
|
selfNodeId={selfNodeId}
|
||||||
|
alerts={alerts}
|
||||||
|
selectedLayerId={selectedId}
|
||||||
|
onSelectLayer={id => {
|
||||||
|
setSelectedId(id);
|
||||||
|
setCursorId(id);
|
||||||
|
}}
|
||||||
|
onClearAlerts={() => setAlerts([])}
|
||||||
|
nodeFps={nodeFps}
|
||||||
|
artnetSenders={artnetSenders}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<footer className="footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="toggle-btn"
|
||||||
|
aria-pressed={liveLock}
|
||||||
|
title={
|
||||||
|
liveLock
|
||||||
|
? 'Live-Modus: Strukturänderungen gesperrt, Layerparameter nutzbar (§17.7)'
|
||||||
|
: 'Setup-Modus: Struktur, Patch, Outputs und Netzwerk änderbar (§17.7)'
|
||||||
|
}
|
||||||
|
onClick={toggleOpMode}
|
||||||
|
>
|
||||||
|
{liveLock ? <IconLock size={11} /> : <IconUnlock size={11} />}
|
||||||
|
{liveLock ? ' Live' : ' Setup'}
|
||||||
|
</button>
|
||||||
|
<span className="foot-item">
|
||||||
|
{mergedLayers.length} Layer{selectedLayer !== null ? ' · 1 ausgewählt' : ''}
|
||||||
|
</span>
|
||||||
|
{warnTotal > 0 && (
|
||||||
|
<span className="foot-item foot-warn" title="Layer und Meldungen im Warnzustand">
|
||||||
|
<IconAlertTriangle size={11} />
|
||||||
|
{warnTotal} Warnung{warnTotal !== 1 ? 'en' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{errTotal > 0 && (
|
||||||
|
<span className="foot-item foot-bad" title="Layer und Meldungen im Fehlerzustand">
|
||||||
|
<IconCrossCircle size={11} />
|
||||||
|
{errTotal} Fehler
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cx('foot-item', ws.connected ? 'tone-ok' : 'foot-bad')}
|
||||||
|
title={
|
||||||
|
ws.connected
|
||||||
|
? 'WebSocket zum Control Core verbunden'
|
||||||
|
: 'Keine Verbindung zum Control Core – Reconnect mit Backoff läuft'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={cx('dot', ws.connected ? 'tone-ok' : 'tone-bad')} />
|
||||||
|
{ws.connected ? `Sync · Revision ${ws.revision ?? '—'}` : 'Getrennt'}
|
||||||
|
</span>
|
||||||
|
{health !== null && (
|
||||||
|
<span className="foot-item" title={`Control Core: ${health.status} · Phase ${health.phase}`}>
|
||||||
|
Core {health.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="foot-spacer" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="toggle-btn"
|
||||||
|
aria-pressed={!inspectorCollapsed}
|
||||||
|
title="Inspector ein-/ausklappen"
|
||||||
|
onClick={() => setInspectorCollapsed(v => !v)}
|
||||||
|
>
|
||||||
|
<IconInspectorPanel size={11} /> Inspector
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="toggle-btn"
|
||||||
|
aria-pressed={sidebarVisible}
|
||||||
|
title="Seitenleiste ein-/ausblenden"
|
||||||
|
onClick={() => setSidebarVisible(v => !v)}
|
||||||
|
>
|
||||||
|
<IconSidebarPanel size={11} /> Seitenleiste
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
/**
|
||||||
|
* Unterer Inspector (§17.2, §17.4).
|
||||||
|
*
|
||||||
|
* Tabs: Layer, Source, Transform, Color, FX, DMX, Advanced.
|
||||||
|
* Fest positionierte gemeinsame Parameter, kontextabhängige Felder nach Typ,
|
||||||
|
* Zahlenfelder mit Tippen, Ziehen (Grip) und Feinsteuerung per Tastatur
|
||||||
|
* (Pfeiltasten, Shift = Feinschritt). Escape bricht ab, löst nichts aus.
|
||||||
|
* Vertikal skalierbar über Divider, vollständig einklappbar.
|
||||||
|
* Live-Lock (§17.7): Struktur (Name, DMX-Patch) gesperrt, Layerparameter frei.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import type { ComponentType, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
|
||||||
|
import type { FxSlot, InspectorTab, Layer } from '../types';
|
||||||
|
import { BLEND_MODES, LOOP_MODES, STATUS_LABEL, TYPE_LABEL, cx, formatTimecode, parseNumberInput } from '../utils';
|
||||||
|
import { IconChevronDown, IconChevronUp, StatusGlyph } from '../icons';
|
||||||
|
|
||||||
|
export interface InspectorCallbacks {
|
||||||
|
readonly onPatch: (patch: InspectorPatch) => void;
|
||||||
|
/** Release des Opacity-Overrides (§11.3) über /commands/{id}/release. */
|
||||||
|
readonly onReleaseOpacity: (layerId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectorPatch {
|
||||||
|
readonly name?: string;
|
||||||
|
readonly blend?: string;
|
||||||
|
readonly loopMode?: string;
|
||||||
|
readonly timeDurationSec?: number;
|
||||||
|
readonly generatorPhase?: number;
|
||||||
|
readonly transformX?: number;
|
||||||
|
readonly transformY?: number;
|
||||||
|
readonly transformScale?: number;
|
||||||
|
readonly transformRotation?: number;
|
||||||
|
readonly colorBrightness?: number;
|
||||||
|
readonly colorContrast?: number;
|
||||||
|
readonly colorSaturation?: number;
|
||||||
|
readonly colorHue?: number;
|
||||||
|
readonly fxBypass?: Readonly<[number | null, boolean]>;
|
||||||
|
readonly dmxUniverse?: number;
|
||||||
|
readonly dmxAddress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectorProps extends InspectorCallbacks {
|
||||||
|
readonly layer: Layer | null;
|
||||||
|
readonly activeTab: InspectorTab;
|
||||||
|
readonly onTabChange: (tab: InspectorTab) => void;
|
||||||
|
readonly collapsed: boolean;
|
||||||
|
readonly height: number;
|
||||||
|
readonly liveLock: boolean;
|
||||||
|
readonly onToggleCollapse: () => void;
|
||||||
|
readonly onResizeStart: (e: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
|
readonly resizing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: ReadonlyArray<{ readonly id: InspectorTab; readonly label: string }> = [
|
||||||
|
{ id: 'layer', label: 'Layer' },
|
||||||
|
{ id: 'source', label: 'Source' },
|
||||||
|
{ id: 'transform', label: 'Transform' },
|
||||||
|
{ id: 'color', label: 'Color' },
|
||||||
|
{ id: 'fx', label: 'FX' },
|
||||||
|
{ id: 'dmx', label: 'DMX' },
|
||||||
|
{ id: 'advanced', label: 'Advanced' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
* Zahlenfeld: Tippen, Ziehen am Grip, Feinsteuerung per Tastatur.
|
||||||
|
* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface NumFieldProps {
|
||||||
|
readonly label: string;
|
||||||
|
readonly value: number;
|
||||||
|
readonly unit?: string;
|
||||||
|
readonly step?: number;
|
||||||
|
readonly disabled?: boolean;
|
||||||
|
readonly onCommit: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumField({ label, value, unit, step = 1, disabled = false, onCommit }: NumFieldProps) {
|
||||||
|
const [draft, setDraft] = useState<string | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const drag = useRef<{ readonly startValue: number; readonly startX: number } | null>(null);
|
||||||
|
|
||||||
|
const shown = draft ?? String(value);
|
||||||
|
|
||||||
|
const commitDraft = (raw: string): void => {
|
||||||
|
const parsed = parseNumberInput(raw);
|
||||||
|
setDraft(null);
|
||||||
|
if (parsed !== null) onCommit(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nudge = (delta: number): void => {
|
||||||
|
const next = Math.round((value + delta) * 1000) / 1000;
|
||||||
|
onCommit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGripDown = (e: ReactPointerEvent<HTMLSpanElement>): void => {
|
||||||
|
if (disabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
drag.current = { startValue: value, startX: e.clientX };
|
||||||
|
(e.target as HTMLSpanElement).setPointerCapture(e.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGripMove = (e: ReactPointerEvent<HTMLSpanElement>): void => {
|
||||||
|
const d = drag.current;
|
||||||
|
if (d === null) return;
|
||||||
|
const raw = d.startValue + (e.clientX - d.startX) * step;
|
||||||
|
onCommit(Math.round(raw * 1000) / 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGripUp = (): void => {
|
||||||
|
drag.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<span className="field-label">{label}</span>
|
||||||
|
<div className="numfield">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className="mono"
|
||||||
|
value={shown}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (draft !== null) commitDraft(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
commitDraft((e.target as HTMLInputElement).value);
|
||||||
|
(e.target as HTMLInputElement).blur();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setDraft(null);
|
||||||
|
(e.target as HTMLInputElement).blur();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
nudge(e.shiftKey ? step / 10 : step);
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
nudge(e.shiftKey ? -(step / 10) : -step);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{unit !== undefined && <span className="note">{unit}</span>}
|
||||||
|
<span
|
||||||
|
className="drag-grip"
|
||||||
|
role="slider"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
aria-label={`${label} ziehen`}
|
||||||
|
onPointerDown={onGripDown}
|
||||||
|
onPointerMove={onGripMove}
|
||||||
|
onPointerUp={onGripUp}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'ArrowUp') nudge(e.shiftKey ? step / 10 : step);
|
||||||
|
if (e.key === 'ArrowDown') nudge(e.shiftKey ? -(step / 10) : -step);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⇄
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
* Kleine Bausteine
|
||||||
|
* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function Field({ label, children }: { readonly label: string; readonly children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<span className="field-label">{label}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldValue({ label, value, title }: { readonly label: string; readonly value: string; readonly title?: string }) {
|
||||||
|
return (
|
||||||
|
<Field label={label}>
|
||||||
|
<span className="field-value" title={title ?? value}>
|
||||||
|
<span className="ellip">{value}</span>
|
||||||
|
</span>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Group({ title, children }: { readonly title: string; readonly children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="field-group">
|
||||||
|
<div className="group-title">{title}</div>
|
||||||
|
<div className="field-grid">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fxStateText(state: FxSlot['state']): string {
|
||||||
|
return state === 'active' ? 'aktiv' : state === 'bypassed' ? 'bypassed' : 'Fehler';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
* Tabs
|
||||||
|
* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface TabBodyProps extends InspectorCallbacks {
|
||||||
|
readonly layer: Layer;
|
||||||
|
readonly liveLock: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LayerTabBody({ layer, liveLock, onPatch }: TabBodyProps) {
|
||||||
|
return (
|
||||||
|
<Group title="Layer">
|
||||||
|
<Field label="Name (Struktur – Live gesperrt)">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={layer.name}
|
||||||
|
disabled={liveLock}
|
||||||
|
aria-label="Layername"
|
||||||
|
title={liveLock ? 'Umbenennen in Live gesperrt (§17.7)' : layer.name}
|
||||||
|
onChange={(e) => onPatch({ name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<FieldValue label="Typ" value={TYPE_LABEL[layer.type]} />
|
||||||
|
<FieldValue label="Status" value={STATUS_LABEL[layer.status]} title={`Betriebszustand: ${STATUS_LABEL[layer.status]}`} />
|
||||||
|
<Field label="Blend">
|
||||||
|
<select
|
||||||
|
value={layer.blend}
|
||||||
|
aria-label="Blend-Modus"
|
||||||
|
onChange={(e) => onPatch({ blend: e.target.value })}
|
||||||
|
>
|
||||||
|
{BLEND_MODES.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Loop">
|
||||||
|
<select
|
||||||
|
value={layer.loopMode}
|
||||||
|
aria-label="Loop-Modus"
|
||||||
|
onChange={(e) => onPatch({ loopMode: e.target.value })}
|
||||||
|
>
|
||||||
|
{LOOP_MODES.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceTabBody({ layer, onPatch }: TabBodyProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Group title="Quelle">
|
||||||
|
<FieldValue label="Clip / Generator" value={layer.sourceName} title={layer.sourceName} />
|
||||||
|
<FieldValue label="Bank / Index" value={layer.sourceBank} />
|
||||||
|
<FieldValue label="Ziel" value={layer.target} title={layer.target} />
|
||||||
|
</Group>
|
||||||
|
<Group title="Zeiten">
|
||||||
|
<NumField
|
||||||
|
label="Dauer"
|
||||||
|
value={layer.timeDurationSec}
|
||||||
|
unit="s · 0 = ∞"
|
||||||
|
step={0.1}
|
||||||
|
onCommit={(v) => onPatch({ timeDurationSec: Math.max(0, v) })}
|
||||||
|
/>
|
||||||
|
<FieldValue
|
||||||
|
label="Position"
|
||||||
|
value={formatTimecode(layer.timePositionSec)}
|
||||||
|
title={`aktuelle Wiedergabeposition ${formatTimecode(layer.timePositionSec)}`}
|
||||||
|
/>
|
||||||
|
{layer.type === 'generator' && (
|
||||||
|
<NumField
|
||||||
|
label="Generatorphase"
|
||||||
|
value={layer.generatorPhase}
|
||||||
|
unit="s"
|
||||||
|
step={0.01}
|
||||||
|
onCommit={(v) => onPatch({ generatorPhase: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransformTabBody({ layer, onPatch }: TabBodyProps) {
|
||||||
|
return (
|
||||||
|
<Group title="Transform">
|
||||||
|
<NumField label="X" value={layer.transform.x} unit="px" step={1} onCommit={(v) => onPatch({ transformX: v })} />
|
||||||
|
<NumField label="Y" value={layer.transform.y} unit="px" step={1} onCommit={(v) => onPatch({ transformY: v })} />
|
||||||
|
<NumField label="Scale" value={layer.transform.scale} unit="%" step={0.5} onCommit={(v) => onPatch({ transformScale: v })} />
|
||||||
|
<NumField label="Rotation" value={layer.transform.rotation} unit="°" step={1} onCommit={(v) => onPatch({ transformRotation: v })} />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorTabBody({ layer, onPatch }: TabBodyProps) {
|
||||||
|
return (
|
||||||
|
<Group title="Farbkorrektur">
|
||||||
|
<NumField label="Brightness" value={layer.color.brightness} unit="%" step={1} onCommit={(v) => onPatch({ colorBrightness: v })} />
|
||||||
|
<NumField label="Contrast" value={layer.color.contrast} unit="%" step={1} onCommit={(v) => onPatch({ colorContrast: v })} />
|
||||||
|
<NumField label="Saturation" value={layer.color.saturation} unit="%" step={1} onCommit={(v) => onPatch({ colorSaturation: v })} />
|
||||||
|
<NumField label="Hue" value={layer.color.hue} unit="°" step={1} onCommit={(v) => onPatch({ colorHue: v })} />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FxTabBody({ layer, onPatch }: TabBodyProps) {
|
||||||
|
return (
|
||||||
|
<Group title="Effekt-Slots (2)">
|
||||||
|
{layer.fxSlots.map((slot, idx) => (
|
||||||
|
<div className="field" key={idx}>
|
||||||
|
<span className="field-label">Slot {idx + 1}</span>
|
||||||
|
{slot === null ? (
|
||||||
|
<span className="field-value">
|
||||||
|
<span className="ellip">leer</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="field-value" title={`${slot.name} – ${fxStateText(slot.state)}`}>
|
||||||
|
<span className="ellip">{slot.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="toggle-btn"
|
||||||
|
aria-pressed={slot.state === 'bypassed'}
|
||||||
|
title={slot.state === 'bypassed' ? 'Bypass aufheben' : 'Effekt bypassen'}
|
||||||
|
onClick={() => onPatch({ fxBypass: [idx, slot.state !== 'bypassed'] })}
|
||||||
|
>
|
||||||
|
{slot.state === 'bypassed' ? 'Bypassed' : 'Aktiv'}
|
||||||
|
</button>
|
||||||
|
{slot.state === 'error' && <span className="tone-bad">ERR</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DmxTabBody({ layer, liveLock, onPatch }: TabBodyProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Group title="Art-Net-Patch (16-Bit, §16)">
|
||||||
|
<NumField
|
||||||
|
label="Universe (Struktur)"
|
||||||
|
value={layer.dmx.universe}
|
||||||
|
step={1}
|
||||||
|
disabled={liveLock}
|
||||||
|
onCommit={(v) => onPatch({ dmxUniverse: Math.max(0, Math.min(63, Math.round(v))) })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label="Adresse (Struktur)"
|
||||||
|
value={layer.dmx.address}
|
||||||
|
step={1}
|
||||||
|
disabled={liveLock}
|
||||||
|
onCommit={(v) => onPatch({ dmxAddress: Math.max(1, Math.min(512, Math.round(v))) })}
|
||||||
|
/>
|
||||||
|
<FieldValue label="Modus" value={layer.dmx.mode} />
|
||||||
|
</Group>
|
||||||
|
<p className="note">
|
||||||
|
DMX-Werte sind numerisch präzise editierbar (16 Bit). Patch-Änderungen erfordern
|
||||||
|
Setup-Modus und werden serverseitig bestätigt.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdvancedTabBody({ layer, onReleaseOpacity }: TabBodyProps) {
|
||||||
|
return (
|
||||||
|
<Group title="Automation / Ownership">
|
||||||
|
<FieldValue label="Steuerquellen" value={layer.control.sources.join(' · ')} />
|
||||||
|
<FieldValue label="Aktueller Owner" value={layer.control.owner} title={layer.control.owner} />
|
||||||
|
<FieldValue label="Opacity-Parameter" value={`composition/…/layer/${layer.id.slice(0, 8)}…/opacity`} title="Pfad folgt §10.2 (UUID-basiert)" />
|
||||||
|
<div className="field">
|
||||||
|
<span className="field-label">Override</span>
|
||||||
|
<div className="btn-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
title="Web-Override des Opacity-Parameters freigeben (§11.3)"
|
||||||
|
onClick={() => onReleaseOpacity(layer.id)}
|
||||||
|
>
|
||||||
|
Release Opacity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAB_BODIES: Record<InspectorTab, ComponentType<TabBodyProps>> = {
|
||||||
|
layer: LayerTabBody,
|
||||||
|
source: SourceTabBody,
|
||||||
|
transform: TransformTabBody,
|
||||||
|
color: ColorTabBody,
|
||||||
|
fx: FxTabBody,
|
||||||
|
dmx: DmxTabBody,
|
||||||
|
advanced: AdvancedTabBody,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
* Inspector
|
||||||
|
* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export default function Inspector(props: InspectorProps) {
|
||||||
|
const { layer, activeTab, onTabChange, collapsed, height, liveLock, onToggleCollapse, onResizeStart, resizing } = props;
|
||||||
|
const Body = TAB_BODIES[activeTab];
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<div className="insp-collapsed">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn"
|
||||||
|
aria-label="Inspector aufklappen"
|
||||||
|
title="Inspector aufklappen"
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
>
|
||||||
|
<IconChevronUp size={13} />
|
||||||
|
</button>
|
||||||
|
<span>Inspector: {layer !== null ? layer.name : 'keine Auswahl'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="inspector" style={{ height }} aria-label="Inspector">
|
||||||
|
<div
|
||||||
|
className={cx('insp-divider', resizing && 'dragging')}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-label="Inspector-Höhe ändern"
|
||||||
|
onPointerDown={onResizeStart}
|
||||||
|
/>
|
||||||
|
<div className="inspector-head">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === t.id}
|
||||||
|
className="insp-tab"
|
||||||
|
onClick={() => onTabChange(t.id)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="insp-tools">
|
||||||
|
{layer !== null && (
|
||||||
|
<span className={cx('insp-tab')} title="Ausgewählter Layer">
|
||||||
|
<StatusGlyph status={layer.status} size={11} />
|
||||||
|
<span className="ellip" style={{ maxWidth: 120 }}>
|
||||||
|
{layer.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn"
|
||||||
|
aria-label="Inspector einklappen"
|
||||||
|
title="Inspector einklappen"
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
>
|
||||||
|
<IconChevronDown size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="inspector-body" role="tabpanel">
|
||||||
|
{layer === null ? (
|
||||||
|
<div className="insp-empty">Kein Layer ausgewählt – Zeile in der Tabelle wählen.</div>
|
||||||
|
) : (
|
||||||
|
<Body layer={layer} liveLock={liveLock} onPatch={props.onPatch} onReleaseOpacity={props.onReleaseOpacity} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* Layer-Tabelle – primäre Mixeransicht (§17.3).
|
||||||
|
*
|
||||||
|
* Pflichtspalten mit fixen Breiten über --lt-cols (tokens.css):
|
||||||
|
* Status | Layer (Z + Name) | Typ | Source | Target | Time | Opacity | Blend | FX | Control.
|
||||||
|
* Zeilenhöhe 30 px (Desktop-Density), Selektion als Akzentfläche ohne Blinken,
|
||||||
|
* Pfeiltasten-Navigation, Escape bricht Editieren ab (kein Blackout, §17.7).
|
||||||
|
* Der Opacity-Fader committet real per parameter.set (§10.2, §23.2).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||||
|
import type { Layer } from '../types';
|
||||||
|
import { STATUS_LABEL, STATUS_TONE, TYPE_LABEL, cx, formatTimecode } from '../utils';
|
||||||
|
import { StatusGlyph } from '../icons';
|
||||||
|
|
||||||
|
interface LayerTableProps {
|
||||||
|
readonly layers: readonly Layer[];
|
||||||
|
readonly selectedId: string | null;
|
||||||
|
readonly cursorId: string | null;
|
||||||
|
/** Live-Modus sperrt Umbenennen (§17.7). */
|
||||||
|
readonly liveLock: boolean;
|
||||||
|
readonly onSelect: (id: string) => void;
|
||||||
|
readonly onCursor: (id: string) => void;
|
||||||
|
readonly onRename: (id: string, name: string) => void;
|
||||||
|
readonly onOpacityLocal: (id: string, value: number) => void;
|
||||||
|
readonly onOpacityCommit: (id: string, value: number) => void;
|
||||||
|
readonly onEnterPressed: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEADERS: readonly string[] = [
|
||||||
|
'',
|
||||||
|
'Layer',
|
||||||
|
'Typ',
|
||||||
|
'Source',
|
||||||
|
'Target',
|
||||||
|
'Time',
|
||||||
|
'Opacity',
|
||||||
|
'Blend',
|
||||||
|
'FX',
|
||||||
|
'Control',
|
||||||
|
];
|
||||||
|
|
||||||
|
function fxSlotTitle(layer: Layer): string {
|
||||||
|
return layer.fxSlots
|
||||||
|
.map((s, i) =>
|
||||||
|
s === null
|
||||||
|
? `Slot ${i + 1}: leer`
|
||||||
|
: `Slot ${i + 1}: ${s.name} (${s.state === 'active' ? 'aktiv' : s.state === 'bypassed' ? 'bypassed' : 'Fehler'})`,
|
||||||
|
)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LayerTable(props: LayerTableProps) {
|
||||||
|
const { layers, selectedId, cursorId, liveLock } = props;
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [draftName, setDraftName] = useState('');
|
||||||
|
/** Lokaler Fader-Stand während des Ziehens; Commit löst parameter.set aus. */
|
||||||
|
const [dragValue, setDragValue] = useState<{ readonly id: string; readonly value: number } | null>(null);
|
||||||
|
|
||||||
|
const cursorIndex = layers.findIndex((l) => l.id === cursorId);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>): void => {
|
||||||
|
if (editingId !== null) return; // Eingabefelder fangen Tasten nie global ab (§17.7)
|
||||||
|
if (layers.length === 0) return;
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 'ArrowUp': {
|
||||||
|
e.preventDefault();
|
||||||
|
const dir = e.key === 'ArrowDown' ? 1 : -1;
|
||||||
|
const next = Math.min(layers.length - 1, Math.max(0, cursorIndex + dir));
|
||||||
|
const id = layers[next].id;
|
||||||
|
props.onCursor(id);
|
||||||
|
props.onSelect(id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Enter':
|
||||||
|
props.onEnterPressed();
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
// beendet nichts Destruktives; Blackout ist separat geschützt (§17.7)
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginEdit = (layer: Layer): void => {
|
||||||
|
if (liveLock) return;
|
||||||
|
setEditingId(layer.id);
|
||||||
|
setDraftName(layer.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitEdit = (): void => {
|
||||||
|
if (editingId === null) return;
|
||||||
|
const name = draftName.trim();
|
||||||
|
if (name.length > 0) props.onRename(editingId, name);
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const opacityOf = (layer: Layer): number =>
|
||||||
|
dragValue !== null && dragValue.id === layer.id ? dragValue.value : layer.opacity;
|
||||||
|
|
||||||
|
const handleOpacityInput = (layer: Layer, value: number): void => {
|
||||||
|
setDragValue({ id: layer.id, value });
|
||||||
|
props.onOpacityLocal(layer.id, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitOpacity = (layer: Layer): void => {
|
||||||
|
if (dragValue === null || dragValue.id !== layer.id) return;
|
||||||
|
props.onOpacityCommit(layer.id, dragValue.value);
|
||||||
|
setDragValue(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="layer-table"
|
||||||
|
tabIndex={0}
|
||||||
|
role="grid"
|
||||||
|
aria-label="Layer-Tabelle"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div className="lt-head" role="row">
|
||||||
|
{HEADERS.map((h, i) => (
|
||||||
|
<div key={i} className="lt-cell" role="columnheader">
|
||||||
|
{h}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{layers.length === 0 ? (
|
||||||
|
<div className="lt-empty">Keine Layer im Projekt.</div>
|
||||||
|
) : (
|
||||||
|
layers.map((layer) => {
|
||||||
|
const selected = layer.id === selectedId;
|
||||||
|
const isCursor = layer.id === cursorId;
|
||||||
|
const editing = layer.id === editingId;
|
||||||
|
const op = opacityOf(layer);
|
||||||
|
const dur =
|
||||||
|
layer.timeDurationSec === 0 ? '∞' : formatTimecode(layer.timeDurationSec);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={layer.id}
|
||||||
|
role="row"
|
||||||
|
aria-selected={selected}
|
||||||
|
className={cx('lt-row', selected && 'selected', isCursor && 'is-cursor')}
|
||||||
|
onClick={() => {
|
||||||
|
props.onSelect(layer.id);
|
||||||
|
props.onCursor(layer.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Status – Glyphe + Ton + Tooltip, nie Farbe allein (§17.6) */}
|
||||||
|
<div
|
||||||
|
className={cx('lt-cell', 'c-status', STATUS_TONE[layer.status])}
|
||||||
|
role="gridcell"
|
||||||
|
title={STATUS_LABEL[layer.status]}
|
||||||
|
>
|
||||||
|
<StatusGlyph status={layer.status} />
|
||||||
|
<span className="sr-only">{STATUS_LABEL[layer.status]}</span>
|
||||||
|
</div>
|
||||||
|
{/* Layer: Z-Reihenfolge + editierbarer Name */}
|
||||||
|
<div className="lt-cell" role="gridcell">
|
||||||
|
<span className="z-badge mono" title={`Z ${layer.z}`}>
|
||||||
|
{layer.z}
|
||||||
|
</span>
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
className="name-input"
|
||||||
|
value={draftName}
|
||||||
|
autoFocus
|
||||||
|
aria-label="Layer umbenennen"
|
||||||
|
onChange={(e) => setDraftName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter') commitEdit();
|
||||||
|
else if (e.key === 'Escape') setEditingId(null);
|
||||||
|
}}
|
||||||
|
onBlur={commitEdit}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="layer-name ellip"
|
||||||
|
title={
|
||||||
|
liveLock
|
||||||
|
? `${layer.name} (Umbenennen in Live gesperrt)`
|
||||||
|
: `${layer.name} – Doppelklick zum Umbenennen`
|
||||||
|
}
|
||||||
|
onDoubleClick={() => beginEdit(layer)}
|
||||||
|
>
|
||||||
|
{layer.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Typ */}
|
||||||
|
<div className="lt-cell" role="gridcell" title={TYPE_LABEL[layer.type]}>
|
||||||
|
<span className="ellip">{TYPE_LABEL[layer.type]}</span>
|
||||||
|
</div>
|
||||||
|
{/* Source: Clip-/Generatorname + Bank/Index */}
|
||||||
|
<div
|
||||||
|
className="lt-cell"
|
||||||
|
role="gridcell"
|
||||||
|
title={`${layer.sourceName} · ${layer.sourceBank}`}
|
||||||
|
>
|
||||||
|
<span className="ellip">{layer.sourceName}</span>
|
||||||
|
<span className="src-bank mono">{layer.sourceBank}</span>
|
||||||
|
</div>
|
||||||
|
{/* Target */}
|
||||||
|
<div className="lt-cell" role="gridcell" title={layer.target}>
|
||||||
|
<span className="ellip">{layer.target}</span>
|
||||||
|
</div>
|
||||||
|
{/* Time – monospace/tabellarisch (§17.6) */}
|
||||||
|
<div
|
||||||
|
className="lt-cell mono"
|
||||||
|
role="gridcell"
|
||||||
|
title={`Position ${formatTimecode(layer.timePositionSec)} / Dauer ${
|
||||||
|
layer.timeDurationSec === 0 ? 'unbegrenzt' : formatTimecode(layer.timeDurationSec)
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="time-pos">{formatTimecode(layer.timePositionSec)}</span>
|
||||||
|
<span className="time-dur">{dur}</span>
|
||||||
|
</div>
|
||||||
|
{/* Opacity: Wert + schmaler Fader (64 px) */}
|
||||||
|
<div className="lt-cell" role="gridcell" title={`Opacity ${op} %`}>
|
||||||
|
<span className="opacity-val mono">{op}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="opacity-fader"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={op}
|
||||||
|
aria-label={`Opacity von ${layer.name}`}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
onChange={(e) => handleOpacityInput(layer, Number(e.target.value))}
|
||||||
|
onPointerUp={() => commitOpacity(layer)}
|
||||||
|
onKeyUp={() => commitOpacity(layer)}
|
||||||
|
onBlur={() => commitOpacity(layer)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Blend */}
|
||||||
|
<div className="lt-cell" role="gridcell" title={`Blend ${layer.blend}`}>
|
||||||
|
<span className="ellip">{layer.blend}</span>
|
||||||
|
</div>
|
||||||
|
{/* FX: zwei Slots mit Bypass-/Fehlerstatus */}
|
||||||
|
<div className="lt-cell" role="gridcell" title={fxSlotTitle(layer)}>
|
||||||
|
{layer.fxSlots.map((slot, idx) =>
|
||||||
|
slot === null ? (
|
||||||
|
<span key={idx} className="fx-slot" title="Effektslot leer">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={cx(
|
||||||
|
'fx-slot',
|
||||||
|
slot.state === 'bypassed' && 'bypassed',
|
||||||
|
slot.state === 'error' && 'error',
|
||||||
|
)}
|
||||||
|
title={`${slot.name} – ${
|
||||||
|
slot.state === 'active'
|
||||||
|
? 'aktiv'
|
||||||
|
: slot.state === 'bypassed'
|
||||||
|
? 'bypassed'
|
||||||
|
: 'Fehler'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="fx-dot" />
|
||||||
|
<span className="ellip">{slot.name}</span>
|
||||||
|
{slot.state === 'bypassed' && <span>BYP</span>}
|
||||||
|
{slot.state === 'error' && <span>ERR</span>}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Control: Quellen + Owner */}
|
||||||
|
<div
|
||||||
|
className="lt-cell"
|
||||||
|
role="gridcell"
|
||||||
|
title={`Steuerung: ${layer.control.sources.join(', ')} · Owner: ${layer.control.owner}`}
|
||||||
|
>
|
||||||
|
<span className="ctrl-owner ellip">{layer.control.sources.join(' · ')}</span>
|
||||||
|
<span className="ctrl-owner ellip">{layer.control.owner}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* Rechte Seitenleiste (§17.5).
|
||||||
|
*
|
||||||
|
* Umschaltbar zwischen Active Layers (Thumbnail, Position, Node, Status,
|
||||||
|
* Pause/Stop – deaktiviert bis die Transport-API existiert), Nodes
|
||||||
|
* (Anzeigename, Rolle, Health, FPS, Art-Net-Sender) und Alerts (Fehler
|
||||||
|
* mit Zeitstempel). Health nie allein über Farbe: immer Text/Glyphe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AlertItem, ClusterNode, Layer, SidebarTab } from '../types';
|
||||||
|
import { STATUS_LABEL, STATUS_TONE, cx, formatTimecode, formatTimeMs } from '../utils';
|
||||||
|
import { IconAlertTriangle, IconCrossCircle, IconInfoCircle, IconPauseSm, IconPlaySm, IconStopSm, StatusGlyph } from '../icons';
|
||||||
|
|
||||||
|
interface RightSidebarProps {
|
||||||
|
readonly tab: SidebarTab;
|
||||||
|
readonly onTabChange: (tab: SidebarTab) => void;
|
||||||
|
readonly layers: readonly Layer[];
|
||||||
|
readonly nodes: readonly ClusterNode[];
|
||||||
|
readonly selfNodeId: string | null;
|
||||||
|
readonly alerts: readonly AlertItem[];
|
||||||
|
readonly selectedLayerId: string | null;
|
||||||
|
readonly onSelectLayer: (id: string) => void;
|
||||||
|
readonly onClearAlerts: () => void;
|
||||||
|
readonly nodeFps: ReadonlyMap<string, number>;
|
||||||
|
readonly artnetSenders: ReadonlyMap<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEALTH_TONE: Record<ClusterNode['health'], string> = {
|
||||||
|
online: 'tone-ok',
|
||||||
|
degraded: 'tone-warn',
|
||||||
|
stale: 'tone-warn',
|
||||||
|
offline: 'tone-bad',
|
||||||
|
};
|
||||||
|
|
||||||
|
const HEALTH_LABEL: Record<ClusterNode['health'], string> = {
|
||||||
|
online: 'online',
|
||||||
|
degraded: 'degraded',
|
||||||
|
stale: 'veraltet',
|
||||||
|
offline: 'offline',
|
||||||
|
};
|
||||||
|
|
||||||
|
function SeverityIcon({ severity }: { readonly severity: AlertItem['severity'] }) {
|
||||||
|
if (severity === 'error') return <IconCrossCircle size={13} />;
|
||||||
|
if (severity === 'warning') return <IconAlertTriangle size={13} />;
|
||||||
|
return <IconInfoCircle size={13} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActiveLayersTab(props: RightSidebarProps) {
|
||||||
|
const active = props.layers.filter(
|
||||||
|
(l) => l.status === 'active' || l.status === 'preloaded' || l.status === 'out_of_sync',
|
||||||
|
);
|
||||||
|
if (active.length === 0) {
|
||||||
|
return <div className="empty-note">Keine laufenden oder vorgeladenen Layer.</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{active.map((l) => (
|
||||||
|
<div
|
||||||
|
key={l.id}
|
||||||
|
className={cx('side-item', props.selectedLayerId === l.id && 'selected')}
|
||||||
|
onClick={() => props.onSelectLayer(l.id)}
|
||||||
|
>
|
||||||
|
<div className="thumb mono" title={`Thumbnail: ${l.sourceName}`}>
|
||||||
|
{l.sourceBank.slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
<div className="item-main">
|
||||||
|
<div className="item-title">
|
||||||
|
<span className={cx(STATUS_TONE[l.status])}>
|
||||||
|
<StatusGlyph status={l.status} size={11} />
|
||||||
|
</span>
|
||||||
|
<span className="item-name ellip" title={l.name}>
|
||||||
|
{l.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="item-meta">
|
||||||
|
<span className="mono">{formatTimecode(l.timePositionSec)}</span>
|
||||||
|
<span className="ellip" title={`Ziel: ${l.target}`}>
|
||||||
|
{l.target}
|
||||||
|
</span>
|
||||||
|
<span title={`Status: ${STATUS_LABEL[l.status]}`}>{STATUS_LABEL[l.status]}</span>
|
||||||
|
</div>
|
||||||
|
<div className="item-meta">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn"
|
||||||
|
disabled
|
||||||
|
title={`Pause ${l.name}: benötigt Transport-API (spätere Phase)`}
|
||||||
|
>
|
||||||
|
{l.status === 'paused' ? <IconPlaySm size={11} /> : <IconPauseSm size={11} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn"
|
||||||
|
disabled
|
||||||
|
title={`Stop ${l.name}: benötigt Transport-API (spätere Phase)`}
|
||||||
|
>
|
||||||
|
<IconStopSm size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodesTab(props: RightSidebarProps) {
|
||||||
|
if (props.nodes.length === 0) {
|
||||||
|
return <div className="empty-note">Keine Nodes registriert (Control Core nicht erreichbar).</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{props.nodes.map((n) => {
|
||||||
|
const fps = props.nodeFps.get(n.node_id);
|
||||||
|
const sender = props.artnetSenders.get(n.node_id);
|
||||||
|
return (
|
||||||
|
<div key={n.node_id} className="side-item">
|
||||||
|
<div className="item-main">
|
||||||
|
<div className="item-title">
|
||||||
|
<span className={cx('dot', HEALTH_TONE[n.health])} title={`Health: ${HEALTH_LABEL[n.health]}`} />
|
||||||
|
<span className="item-name ellip" title={n.display_name}>
|
||||||
|
{n.display_name}
|
||||||
|
</span>
|
||||||
|
{props.selfNodeId === n.node_id && (
|
||||||
|
<span className="self-tag" title="Dieser Node">
|
||||||
|
(dieser)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="item-meta">
|
||||||
|
<span title={`Rollen: ${n.roles.join(', ')}`}>
|
||||||
|
{n.roles.map((r) => (
|
||||||
|
<span key={r} className="role-chip">
|
||||||
|
{r}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className={cx(HEALTH_TONE[n.health])} title={`Health: ${HEALTH_LABEL[n.health]}`}>
|
||||||
|
{HEALTH_LABEL[n.health]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="item-meta">
|
||||||
|
<span className="mono" title="Frames pro Sekunde (Metrik folgt mit Renderer-IPC)">
|
||||||
|
FPS {fps !== undefined ? fps.toFixed(2) : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="ellip" title={`Art-Net-Sender: ${sender ?? 'nicht gemeldet'}`}>
|
||||||
|
Art-Net {sender ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertsTab(props: RightSidebarProps) {
|
||||||
|
if (props.alerts.length === 0) {
|
||||||
|
return <div className="empty-note">Keine Meldungen.</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{props.alerts.map((a) => (
|
||||||
|
<div key={a.id} className="alert-item">
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
a.severity === 'error' ? 'tone-bad' : a.severity === 'warning' ? 'tone-warn' : 'tone-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SeverityIcon severity={a.severity} />
|
||||||
|
</span>
|
||||||
|
<span className="alert-time mono">{formatTimeMs(a.timeMs)}</span>
|
||||||
|
<span className="alert-msg">{a.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RightSidebar(props: RightSidebarProps) {
|
||||||
|
return (
|
||||||
|
<aside className="sidebar" aria-label="Seitenleiste">
|
||||||
|
<div className="side-head">
|
||||||
|
<div className="seg" role="tablist" aria-label="Seitenleiste-Ansicht">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={props.tab === 'active'}
|
||||||
|
aria-pressed={props.tab === 'active'}
|
||||||
|
onClick={() => props.onTabChange('active')}
|
||||||
|
>
|
||||||
|
Active Layers
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={props.tab === 'nodes'}
|
||||||
|
aria-pressed={props.tab === 'nodes'}
|
||||||
|
onClick={() => props.onTabChange('nodes')}
|
||||||
|
>
|
||||||
|
Nodes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={props.tab === 'alerts'}
|
||||||
|
aria-pressed={props.tab === 'alerts'}
|
||||||
|
onClick={() => props.onTabChange('alerts')}
|
||||||
|
>
|
||||||
|
Alerts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="side-body" role="tabpanel">
|
||||||
|
{props.tab === 'active' && <ActiveLayersTab {...props} />}
|
||||||
|
{props.tab === 'nodes' && <NodesTab {...props} />}
|
||||||
|
{props.tab === 'alerts' && <AlertsTab {...props} />}
|
||||||
|
</div>
|
||||||
|
{props.tab === 'alerts' && (
|
||||||
|
<div className="side-foot">
|
||||||
|
<button type="button" className="clear-btn" onClick={props.onClearAlerts}>
|
||||||
|
Alle löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Obere Statusleiste (§17.2), 32 px hoch.
|
||||||
|
*
|
||||||
|
* Produkt-/Projektname, lokaler Node, Output-Status, „Quality: Auto“,
|
||||||
|
* FPS (monospace, 2 Dezimalstellen), Art-Net und Clock/Sync als kleine
|
||||||
|
* Zustandsanzeigen. Zustand nie allein über Farbe: immer Text/Icon daneben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IconClock, IconMonitor, IconSignal } from '../icons';
|
||||||
|
import { cx, formatFps } from '../utils';
|
||||||
|
|
||||||
|
export interface StatusToneItem {
|
||||||
|
readonly tone: 'ok' | 'warn' | 'bad' | 'muted';
|
||||||
|
readonly label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusBarProps {
|
||||||
|
readonly projectName: string;
|
||||||
|
readonly nodeName: string;
|
||||||
|
readonly output: StatusToneItem;
|
||||||
|
readonly quality: string;
|
||||||
|
readonly fps: number | null;
|
||||||
|
readonly artnet: StatusToneItem;
|
||||||
|
readonly sync: StatusToneItem;
|
||||||
|
readonly clock: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_CLASS: Record<StatusToneItem['tone'], string> = {
|
||||||
|
ok: 'tone-ok',
|
||||||
|
warn: 'tone-warn',
|
||||||
|
bad: 'tone-bad',
|
||||||
|
muted: 'tone-muted',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatusBar(props: StatusBarProps) {
|
||||||
|
const { projectName, nodeName, output, quality, fps, artnet, sync, clock } = props;
|
||||||
|
return (
|
||||||
|
<header className="statusbar">
|
||||||
|
<span className="product">HMS MediaEngine</span>
|
||||||
|
<span className="sep" />
|
||||||
|
<span className="status-item" title={`Projekt: ${projectName}`}>
|
||||||
|
<span className="ellip">{projectName}</span>
|
||||||
|
</span>
|
||||||
|
<span className="sep" />
|
||||||
|
<span className="status-item" title={`Lokaler Node: ${nodeName}`}>
|
||||||
|
<span>Node</span>
|
||||||
|
<span className="value ellip">{nodeName}</span>
|
||||||
|
</span>
|
||||||
|
<div className="status-push">
|
||||||
|
<span
|
||||||
|
className={cx('status-item', TONE_CLASS[output.tone])}
|
||||||
|
title={`Output: ${output.label}`}
|
||||||
|
>
|
||||||
|
<IconMonitor size={13} />
|
||||||
|
<span className="value">{output.label}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="status-item tone-muted"
|
||||||
|
title="Renderqualität – automatische Leistungsanpassung"
|
||||||
|
>
|
||||||
|
<span>Quality:</span>
|
||||||
|
<span className="value">{quality}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="status-item"
|
||||||
|
title="Frame-Rate der Bedienoberfläche (Renderer-Metrik folgt mit IPC-Handshake)"
|
||||||
|
>
|
||||||
|
<span>FPS</span>
|
||||||
|
<span className="value mono">{formatFps(fps)}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cx('status-item', TONE_CLASS[artnet.tone])}
|
||||||
|
title={`Art-Net: ${artnet.label}`}
|
||||||
|
>
|
||||||
|
<IconSignal size={13} />
|
||||||
|
<span className="value">{artnet.label}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cx('status-item', TONE_CLASS[sync.tone])}
|
||||||
|
title={`Zustandssynchronisation: ${sync.label}`}
|
||||||
|
>
|
||||||
|
<span className={cx('dot', TONE_CLASS[sync.tone])} />
|
||||||
|
<span className="value">{sync.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="status-item" title="Lokale Uhr">
|
||||||
|
<IconClock size={13} />
|
||||||
|
<span className="value mono">{clock}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* REST-API-Anbindung des Control Core (§23.2, §17.9).
|
||||||
|
*
|
||||||
|
* Dev: '/api/v1' läuft über den Vite-Proxy auf http://localhost:8000.
|
||||||
|
* Jede Änderung wird serverseitig bestätigt (keine blinde optimistic UI,
|
||||||
|
* §17.9); Fehler landen als ApiError mit Status und Detail beim Aufrufer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ClusterNodesResponse,
|
||||||
|
CommandAck,
|
||||||
|
CommandEnvelope,
|
||||||
|
DiagnosticsResponse,
|
||||||
|
HealthResponse,
|
||||||
|
IdentityResponse,
|
||||||
|
ParametersResponse,
|
||||||
|
ReleaseResponse,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const API_BASE = '/api/v1';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
message: string,
|
||||||
|
readonly detail: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeDetail(status: number, statusText: string, body: unknown): string {
|
||||||
|
if (typeof body === 'object' && body !== null && 'detail' in body) {
|
||||||
|
const detail = (body as { detail: unknown }).detail;
|
||||||
|
if (typeof detail === 'string') return detail;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(detail);
|
||||||
|
} catch {
|
||||||
|
return `${status} ${statusText}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${status} ${statusText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new ApiError(0, `Netzwerkfehler: ${err instanceof Error ? err.message : 'unbekannt'}`, null);
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
let body: unknown = null;
|
||||||
|
if (text.length > 0) {
|
||||||
|
try {
|
||||||
|
body = JSON.parse(text) as unknown;
|
||||||
|
} catch {
|
||||||
|
body = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(response.status, describeDetail(response.status, response.statusText, body), body);
|
||||||
|
}
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GET-Endpunkte (app.py) */
|
||||||
|
|
||||||
|
export const getHealth = (): Promise<HealthResponse> => request<HealthResponse>('/system/health');
|
||||||
|
|
||||||
|
export const getIdentity = (): Promise<IdentityResponse> => request<IdentityResponse>('/system/identity');
|
||||||
|
|
||||||
|
export const getClusterNodes = (): Promise<ClusterNodesResponse> =>
|
||||||
|
request<ClusterNodesResponse>('/cluster/nodes');
|
||||||
|
|
||||||
|
export const getParameters = (): Promise<ParametersResponse> => request<ParametersResponse>('/parameters');
|
||||||
|
|
||||||
|
export const getDiagnostics = (): Promise<DiagnosticsResponse> =>
|
||||||
|
request<DiagnosticsResponse>('/diagnostics');
|
||||||
|
|
||||||
|
/* Commands (§23.2) */
|
||||||
|
|
||||||
|
function newCommandId(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `cmd-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParameterSetArgs {
|
||||||
|
readonly path: string;
|
||||||
|
readonly value: number;
|
||||||
|
readonly expectedRevision: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** parameter.set mit Revision-Prüfung und Idempotenz (§23.2). */
|
||||||
|
export async function sendParameterSet({
|
||||||
|
path,
|
||||||
|
value,
|
||||||
|
expectedRevision,
|
||||||
|
}: ParameterSetArgs): Promise<CommandAck> {
|
||||||
|
const envelope: CommandEnvelope = {
|
||||||
|
command_id: newCommandId(),
|
||||||
|
type: 'parameter.set',
|
||||||
|
expected_revision: expectedRevision,
|
||||||
|
actor: { type: 'web', id: 'operator-session' },
|
||||||
|
payload: { parameter_path: path, value },
|
||||||
|
};
|
||||||
|
return request<CommandAck>('/commands', { method: 'POST', body: JSON.stringify(envelope) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Override-Freigabe (§11.3); Release referenziert das ursprüngliche command_id. */
|
||||||
|
export async function releaseCommand(commandId: string): Promise<ReleaseResponse> {
|
||||||
|
return request<ReleaseResponse>(`/commands/${encodeURIComponent(commandId)}/release`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ source: 'web' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket zum Control Core (§17.9, §3.2).
|
||||||
|
*
|
||||||
|
* - Snapshot beim Connect, danach Deltas (parameter.update) plus Heartbeat.
|
||||||
|
* - Reconnect mit Exponential-Backoff: 1 s, 2 s, 4 s … maximum 30 s.
|
||||||
|
* - Browser-Neustart/-Reload stoppt den Output nicht (§3.2): der Client
|
||||||
|
* sendet keinerlei Commands beim Connect und synchronisiert nur Zustand.
|
||||||
|
* - URL: Same-Origin '/ws' (Dev über den Vite-Proxy → ws://localhost:8000/ws);
|
||||||
|
* alternativ direkt über VITE_WS_URL konfigurierbar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { WsServerEvent } from '../types';
|
||||||
|
|
||||||
|
const RECONNECT_BASE_MS = 1000;
|
||||||
|
const RECONNECT_MAX_MS = 30_000;
|
||||||
|
|
||||||
|
function resolveWsUrl(): string {
|
||||||
|
const override: unknown = import.meta.env.VITE_WS_URL;
|
||||||
|
if (typeof override === 'string' && override.length > 0) return override;
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
return `${protocol}://${window.location.host}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWsServerEvent(data: unknown): data is WsServerEvent {
|
||||||
|
if (typeof data !== 'object' || data === null) return false;
|
||||||
|
const type = (data as { type?: unknown }).type;
|
||||||
|
return type === 'snapshot' || type === 'parameter.update' || type === 'heartbeat';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsState {
|
||||||
|
readonly connected: boolean;
|
||||||
|
/** Engine-Revision aus Snapshot/Updates; null vor der ersten Serverantwort. */
|
||||||
|
readonly revision: number | null;
|
||||||
|
readonly values: Readonly<Record<string, number>>;
|
||||||
|
readonly lastEventMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebSocket(): WsState {
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [revision, setRevision] = useState<number | null>(null);
|
||||||
|
const [values, setValues] = useState<Record<string, number>>({});
|
||||||
|
const [lastEventMs, setLastEventMs] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
let socket: WebSocket | null = null;
|
||||||
|
let timer: number | null = null;
|
||||||
|
let attempt = 0;
|
||||||
|
|
||||||
|
const clearTimer = (): void => {
|
||||||
|
if (timer !== null) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReconnect = (): void => {
|
||||||
|
const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS);
|
||||||
|
attempt += 1;
|
||||||
|
timer = window.setTimeout(connect, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = (): void => {
|
||||||
|
if (disposed) return;
|
||||||
|
socket = new WebSocket(resolveWsUrl());
|
||||||
|
socket.onopen = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
attempt = 0;
|
||||||
|
setConnected(true);
|
||||||
|
};
|
||||||
|
socket.onmessage = (ev: MessageEvent<string>) => {
|
||||||
|
if (disposed) return;
|
||||||
|
setLastEventMs(Date.now());
|
||||||
|
const parsed: unknown = (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(ev.data) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (!isWsServerEvent(parsed)) return;
|
||||||
|
switch (parsed.type) {
|
||||||
|
case 'snapshot':
|
||||||
|
setValues(parsed.values);
|
||||||
|
setRevision(parsed.revision);
|
||||||
|
break;
|
||||||
|
case 'parameter.update':
|
||||||
|
setValues(prev => ({ ...prev, [parsed.parameter_path]: parsed.value }));
|
||||||
|
setRevision(parsed.revision);
|
||||||
|
break;
|
||||||
|
case 'heartbeat':
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
setConnected(false);
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
|
socket.onerror = () => {
|
||||||
|
socket?.close();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
clearTimer();
|
||||||
|
socket?.close(1000, 'client-unmount');
|
||||||
|
socket = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { connected, revision, values, lastEventMs };
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Inline-SVG-Iconset (16-px-Basis, currentColor, keine externen Assets).
|
||||||
|
* Eigene schlichte Glyphen statt QLab-Übernahmen (§17.1). Jeder Layer-Zustand
|
||||||
|
* erhält eine eigene Zeichenform, damit Status nie allein über Farbe läuft (§17.6).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ComponentType, ReactNode, SVGProps } from 'react';
|
||||||
|
import type { LayerStatus } from './types';
|
||||||
|
|
||||||
|
export interface IconProps extends SVGProps<SVGSVGElement> {
|
||||||
|
readonly size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Svg({ size = 16, children, ...rest }: IconProps & { readonly children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Werkzeugleiste (§17.2) ---------- */
|
||||||
|
|
||||||
|
export const IconMixer = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M6 4v16M12 4v16M18 4v16" />
|
||||||
|
<circle cx="6" cy="9" r="2" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="12" cy="15" r="2" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="18" cy="7" r="2" fill="currentColor" stroke="none" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconMedia = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<rect x="3.5" y="5.5" width="17" height="13" rx="1.5" />
|
||||||
|
<path d="M10.5 9.5v5l4.5-2.5z" fill="currentColor" stroke="none" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconEffects = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M12 4l1.8 6.2L20 12l-6.2 1.8L12 20l-1.8-6.2L4 12l6.2-1.8z" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconPresets = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<rect x="4" y="4" width="7" height="7" rx="1" />
|
||||||
|
<rect x="13" y="4" width="7" height="7" rx="1" />
|
||||||
|
<rect x="4" y="13" width="7" height="7" rx="1" />
|
||||||
|
<rect x="13" y="13" width="7" height="7" rx="1" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconOutputs = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<rect x="3.5" y="5" width="17" height="12" rx="1.5" />
|
||||||
|
<path d="M9 21h6M12 17v4" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconNetwork = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<circle cx="12" cy="5.5" r="2.2" />
|
||||||
|
<circle cx="5" cy="18.5" r="2.2" />
|
||||||
|
<circle cx="19" cy="18.5" r="2.2" />
|
||||||
|
<path d="M11 7.4L6.2 16.6M13 7.4l4.8 9.2M7.2 18.5h9.6" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconPlugins = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<rect x="3.5" y="3.5" width="10" height="10" rx="1.5" />
|
||||||
|
<rect x="10.5" y="10.5" width="10" height="10" rx="1.5" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconDiagnostics = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M3 12h4l2.5-6 4.5 12 2.5-6h4.5" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---------- Layer-Zustandsglyphen (§17.3, §17.10) ---------- */
|
||||||
|
|
||||||
|
export const IconStActive = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M9 6.8v10.4l8.5-5.2z" fill="currentColor" stroke="none" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconStPaused = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M8.5 6.5h2.6v11H8.5zM12.9 6.5h2.6v11h-2.6z" fill="currentColor" stroke="none" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconStPreloaded = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<circle cx="12" cy="12" r="7" />
|
||||||
|
<circle cx="12" cy="12" r="2.4" fill="currentColor" stroke="none" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconStWarning = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M12 4.5L20.6 19.2H3.4z" />
|
||||||
|
<path d="M12 10.2v3.6M12 16.6v.01" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconStError = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<circle cx="12" cy="12" r="7.2" />
|
||||||
|
<path d="M9.2 9.2l5.6 5.6M14.8 9.2l-5.6 5.6" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconStOffline = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<circle cx="12" cy="12" r="7.2" />
|
||||||
|
<path d="M6.9 6.9l10.2 10.2" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconStOutOfSync = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M3.5 12h3.2l2.2-4.5 4.2 9 2.2-4.5h5.2" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const STATUS_ICON: Record<LayerStatus, ComponentType<IconProps>> = {
|
||||||
|
active: IconStActive,
|
||||||
|
paused: IconStPaused,
|
||||||
|
preloaded: IconStPreloaded,
|
||||||
|
warning: IconStWarning,
|
||||||
|
error: IconStError,
|
||||||
|
offline: IconStOffline,
|
||||||
|
out_of_sync: IconStOutOfSync,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Zustandssymbol: Form kodiert den Zustand, Farbe verstärkt (§17.6). */
|
||||||
|
export function StatusGlyph({ status, size = 13 }: { readonly status: LayerStatus; readonly size?: number }) {
|
||||||
|
const Icon = STATUS_ICON[status];
|
||||||
|
return <Icon size={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Bedienelemente ---------- */
|
||||||
|
|
||||||
|
export const IconChevronDown = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M6 9.5l6 6 6-6" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconChevronUp = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M6 14.5l6-6 6 6" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconChevronLeft = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M14.5 6l-6 6 6 6" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconChevronRight = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M9.5 6l6 6-6 6" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconClock = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<circle cx="12" cy="12" r="8" />
|
||||||
|
<path d="M12 7.5V12l3 2" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconSignal = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M4.5 13.5a10.5 10.5 0 0 1 15 0M8 16.5a5.7 5.7 0 0 1 8 0" />
|
||||||
|
<circle cx="12" cy="19.5" r="1" fill="currentColor" stroke="none" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconMonitor = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<rect x="3.5" y="5" width="17" height="12" rx="1.5" />
|
||||||
|
<path d="M9 20h6" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconLock = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<rect x="5.5" y="11" width="13" height="8.5" rx="1.5" />
|
||||||
|
<path d="M8.5 11V8a3.5 3.5 0 0 1 7 0v3" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconUnlock = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<rect x="5.5" y="11" width="13" height="8.5" rx="1.5" />
|
||||||
|
<path d="M8.5 11V8a3.5 3.5 0 0 1 6.8-1.2" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconAlertTriangle = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M12 4.5L20.6 19.2H3.4z" />
|
||||||
|
<path d="M12 10.2v3.6M12 16.6v.01" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconCrossCircle = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<circle cx="12" cy="12" r="7.2" />
|
||||||
|
<path d="M9.2 9.2l5.6 5.6M14.8 9.2l-5.6 5.6" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconInfoCircle = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<circle cx="12" cy="12" r="8" />
|
||||||
|
<path d="M12 11v5M12 7.8v.01" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconPlaySm = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M9.5 7.5v9l7-4.5z" fill="currentColor" stroke="none" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconPauseSm = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M9 7.5h2.2v9H9zM12.8 7.5H15v9h-2.2z" fill="currentColor" stroke="none" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconStopSm = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<rect x="8" y="8" width="8" height="8" rx="1" fill="currentColor" stroke="none" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconSidebarPanel = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<rect x="3.5" y="5" width="17" height="14" rx="1.5" />
|
||||||
|
<path d="M14.5 5v14" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconInspectorPanel = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<rect x="3.5" y="5" width="17" height="14" rx="1.5" />
|
||||||
|
<path d="M3.5 14.5h17" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconGrip = (p: IconProps) => (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path d="M9 8.5L5.5 12 9 15.5M15 8.5L18.5 12 15 15.5" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './styles/tokens.css';
|
||||||
|
import './styles/app.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (container === null) {
|
||||||
|
throw new Error('#root nicht gefunden – index.html prüfen');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* MVP-Datenbestand der Layer-Tabelle (PLATZHALTER).
|
||||||
|
*
|
||||||
|
* Bis die Layer-API des Control Core existiert, sind Namen, Zustände und
|
||||||
|
* Parameter Demo-Werte. Echte Opacity-Werte laufen bereits über die
|
||||||
|
* Parameter-Engine: composition/{uuid}/layer/{uuid}/opacity (§10.2, §11).
|
||||||
|
* Alles andere bleibt clientseitig und wird später serverseitig bestätigt
|
||||||
|
* (§17.9). Die Layer-IDs sind stabile UUIDs, damit Parameterpfade gültig
|
||||||
|
* bleiben (paths.validate_parameter_path).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Layer } from './types';
|
||||||
|
|
||||||
|
/** Demo-Composition-ID (UUID, §10.2). */
|
||||||
|
export const SEED_COMPOSITION_ID = 'd2a4c6e8-1b3f-4a5c-9d7e-6f8a0b2c4d6e';
|
||||||
|
|
||||||
|
const IDENTITY_TRANSFORM = { x: 0, y: 0, scale: 100, rotation: 0 } as const;
|
||||||
|
const IDENTITY_COLOR = { brightness: 100, contrast: 100, saturation: 100, hue: 0 } as const;
|
||||||
|
const DEFAULT_DMX = { universe: 0, address: 1, mode: '16 Bit' } as const;
|
||||||
|
|
||||||
|
export const SEED_LAYERS: readonly Layer[] = [
|
||||||
|
{
|
||||||
|
id: '9b1f4c2a-3d5e-4f6a-8b7c-1a2b3c4d5e6f',
|
||||||
|
z: 12,
|
||||||
|
name: 'Logo Bug',
|
||||||
|
type: 'media',
|
||||||
|
status: 'active',
|
||||||
|
sourceName: 'logo_main.png',
|
||||||
|
sourceBank: 'B1·04',
|
||||||
|
target: 'Local',
|
||||||
|
timePositionSec: 42.3,
|
||||||
|
timeDurationSec: 3600,
|
||||||
|
opacity: 100,
|
||||||
|
blend: 'Normal',
|
||||||
|
fxSlots: [{ name: 'Key Spill Suppress', state: 'active' }, null],
|
||||||
|
control: { sources: ['web'], owner: 'Web-Operator' },
|
||||||
|
loopMode: 'Loop',
|
||||||
|
generatorPhase: 0,
|
||||||
|
transform: IDENTITY_TRANSFORM,
|
||||||
|
color: IDENTITY_COLOR,
|
||||||
|
dmx: DEFAULT_DMX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8c2e5d3b-4e6f-4a7b-9c8d-2b3c4d5e6f7a',
|
||||||
|
z: 11,
|
||||||
|
name: 'Vignette Key',
|
||||||
|
type: 'adjustment',
|
||||||
|
status: 'active',
|
||||||
|
sourceName: 'vignette_soft',
|
||||||
|
sourceBank: 'ADJ',
|
||||||
|
target: 'Local',
|
||||||
|
timePositionSec: 0,
|
||||||
|
timeDurationSec: 0,
|
||||||
|
opacity: 40,
|
||||||
|
blend: 'Multiply',
|
||||||
|
fxSlots: [null, null],
|
||||||
|
control: { sources: ['web'], owner: 'Web-Operator' },
|
||||||
|
loopMode: 'Hold',
|
||||||
|
generatorPhase: 0,
|
||||||
|
transform: IDENTITY_TRANSFORM,
|
||||||
|
color: IDENTITY_COLOR,
|
||||||
|
dmx: DEFAULT_DMX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7d3f6e4c-5f7a-4b8c-ad9e-3c4d5e6f7a8b',
|
||||||
|
z: 10,
|
||||||
|
name: 'Strobe Accent',
|
||||||
|
type: 'generator',
|
||||||
|
status: 'active',
|
||||||
|
sourceName: 'Strobe Pulse 8 Hz',
|
||||||
|
sourceBank: 'GEN',
|
||||||
|
target: 'Local',
|
||||||
|
timePositionSec: 3.2,
|
||||||
|
timeDurationSec: 0,
|
||||||
|
opacity: 70,
|
||||||
|
blend: 'Add',
|
||||||
|
fxSlots: [{ name: 'Strobe', state: 'active' }, null],
|
||||||
|
control: { sources: ['artnet'], owner: 'Lichtpult 1' },
|
||||||
|
loopMode: 'Kontinuierlich',
|
||||||
|
generatorPhase: 0.125,
|
||||||
|
transform: IDENTITY_TRANSFORM,
|
||||||
|
color: IDENTITY_COLOR,
|
||||||
|
dmx: { universe: 2, address: 49, mode: '16 Bit' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6e4a7f5d-6a8b-4c9d-beaf-4d5e6f7a8b9c',
|
||||||
|
z: 9,
|
||||||
|
name: 'Color Wash',
|
||||||
|
type: 'generator',
|
||||||
|
status: 'active',
|
||||||
|
sourceName: 'Warm Gradient',
|
||||||
|
sourceBank: 'GEN',
|
||||||
|
target: 'Gruppe Ost',
|
||||||
|
timePositionSec: 12.4,
|
||||||
|
timeDurationSec: 0,
|
||||||
|
opacity: 85,
|
||||||
|
blend: 'Screen',
|
||||||
|
fxSlots: [{ name: 'Blur', state: 'bypassed' }, { name: 'Color Trim', state: 'active' }],
|
||||||
|
control: { sources: ['web'], owner: 'Web-Operator' },
|
||||||
|
loopMode: 'Kontinuierlich',
|
||||||
|
generatorPhase: 12.4,
|
||||||
|
transform: IDENTITY_TRANSFORM,
|
||||||
|
color: { brightness: 100, contrast: 100, saturation: 120, hue: 12 },
|
||||||
|
dmx: { universe: 2, address: 57, mode: '16 Bit' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5f5b8a6e-7b9c-4dae-afb0-5e6f7a8b9cad',
|
||||||
|
z: 8,
|
||||||
|
name: 'Band Cam PIP',
|
||||||
|
type: 'media',
|
||||||
|
status: 'preloaded',
|
||||||
|
sourceName: 'cam_front_feed',
|
||||||
|
sourceBank: 'B2·01',
|
||||||
|
target: 'Local',
|
||||||
|
timePositionSec: 0,
|
||||||
|
timeDurationSec: 0,
|
||||||
|
opacity: 100,
|
||||||
|
blend: 'Normal',
|
||||||
|
fxSlots: [null, null],
|
||||||
|
control: { sources: ['web'], owner: 'Web-Operator' },
|
||||||
|
loopMode: 'Loop',
|
||||||
|
generatorPhase: 0,
|
||||||
|
transform: { x: 420, y: 210, scale: 32, rotation: 0 },
|
||||||
|
color: IDENTITY_COLOR,
|
||||||
|
dmx: DEFAULT_DMX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4a6c9b7f-8cad-4ebf-b0a1-6f7a8b9cadbe',
|
||||||
|
z: 7,
|
||||||
|
name: 'Titles Gruppe',
|
||||||
|
type: 'group',
|
||||||
|
status: 'active',
|
||||||
|
sourceName: 'Logo + Lower Third',
|
||||||
|
sourceBank: 'GRP',
|
||||||
|
target: 'Local',
|
||||||
|
timePositionSec: 0,
|
||||||
|
timeDurationSec: 0,
|
||||||
|
opacity: 100,
|
||||||
|
blend: 'Normal',
|
||||||
|
fxSlots: [null, null],
|
||||||
|
control: { sources: ['web'], owner: 'Web-Operator' },
|
||||||
|
loopMode: 'Hold',
|
||||||
|
generatorPhase: 0,
|
||||||
|
transform: IDENTITY_TRANSFORM,
|
||||||
|
color: IDENTITY_COLOR,
|
||||||
|
dmx: DEFAULT_DMX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3b7dad8a-9dbe-4fcf-a1b2-7a8b9cadbecf',
|
||||||
|
z: 6,
|
||||||
|
name: 'Lower Third Showname',
|
||||||
|
type: 'media',
|
||||||
|
status: 'paused',
|
||||||
|
sourceName: 'lowerthird_v3.png',
|
||||||
|
sourceBank: 'B1·09',
|
||||||
|
target: 'Local',
|
||||||
|
timePositionSec: 5.2,
|
||||||
|
timeDurationSec: 30,
|
||||||
|
opacity: 100,
|
||||||
|
blend: 'Normal',
|
||||||
|
fxSlots: [null, null],
|
||||||
|
control: { sources: ['timeline'], owner: 'Timeline Intro' },
|
||||||
|
loopMode: 'Once',
|
||||||
|
generatorPhase: 0,
|
||||||
|
transform: { x: 0, y: 320, scale: 100, rotation: 0 },
|
||||||
|
color: IDENTITY_COLOR,
|
||||||
|
dmx: DEFAULT_DMX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2c8ebc9b-aecf-4da0-b2c3-8b9cadbecfd0',
|
||||||
|
z: 5,
|
||||||
|
name: 'Sync Test Pattern',
|
||||||
|
type: 'generator',
|
||||||
|
status: 'out_of_sync',
|
||||||
|
sourceName: 'Grid 1080',
|
||||||
|
sourceBank: 'GEN',
|
||||||
|
target: 'Gruppe Ost',
|
||||||
|
timePositionSec: 3.1,
|
||||||
|
timeDurationSec: 60,
|
||||||
|
opacity: 100,
|
||||||
|
blend: 'Normal',
|
||||||
|
fxSlots: [null, null],
|
||||||
|
control: { sources: ['automation'], owner: 'Auto-Sync' },
|
||||||
|
loopMode: 'Loop',
|
||||||
|
generatorPhase: 3.1,
|
||||||
|
transform: IDENTITY_TRANSFORM,
|
||||||
|
color: IDENTITY_COLOR,
|
||||||
|
dmx: DEFAULT_DMX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '1d9fcdac-bfd0-4eb1-c3d4-9cadbecfd0e1',
|
||||||
|
z: 4,
|
||||||
|
name: 'Long Loop 4K',
|
||||||
|
type: 'media',
|
||||||
|
status: 'warning',
|
||||||
|
sourceName: 'ambient_4k_loop.mov',
|
||||||
|
sourceBank: 'B3·02',
|
||||||
|
target: 'Local',
|
||||||
|
timePositionSec: 124.8,
|
||||||
|
timeDurationSec: 600,
|
||||||
|
opacity: 85,
|
||||||
|
blend: 'Normal',
|
||||||
|
fxSlots: [{ name: 'Decode LUT', state: 'active' }, null],
|
||||||
|
control: { sources: ['web'], owner: 'Web-Operator' },
|
||||||
|
loopMode: 'Loop',
|
||||||
|
generatorPhase: 0,
|
||||||
|
transform: IDENTITY_TRANSFORM,
|
||||||
|
color: IDENTITY_COLOR,
|
||||||
|
dmx: DEFAULT_DMX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '0eafdecf-cfe1-4fc2-d4e5-adbecfd0e1f2',
|
||||||
|
z: 3,
|
||||||
|
name: 'Backup Node Feed',
|
||||||
|
type: 'media',
|
||||||
|
status: 'offline',
|
||||||
|
sourceName: 'backup_feed.mov',
|
||||||
|
sourceBank: 'B2·07',
|
||||||
|
target: 'Node backstage-02',
|
||||||
|
timePositionSec: 0,
|
||||||
|
timeDurationSec: 0,
|
||||||
|
opacity: 100,
|
||||||
|
blend: 'Normal',
|
||||||
|
fxSlots: [null, null],
|
||||||
|
control: { sources: ['web'], owner: 'Web-Operator' },
|
||||||
|
loopMode: 'Loop',
|
||||||
|
generatorPhase: 0,
|
||||||
|
transform: IDENTITY_TRANSFORM,
|
||||||
|
color: IDENTITY_COLOR,
|
||||||
|
dmx: DEFAULT_DMX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'af1e2d3c-4d5e-4f6a-8b9c-3c4d5e6f7a8b',
|
||||||
|
z: 2,
|
||||||
|
name: 'Sponsors Clip defekt',
|
||||||
|
type: 'media',
|
||||||
|
status: 'error',
|
||||||
|
sourceName: 'sponsors_v2.mp4',
|
||||||
|
sourceBank: 'B3·11',
|
||||||
|
target: 'Local',
|
||||||
|
timePositionSec: 0,
|
||||||
|
timeDurationSec: 8.4,
|
||||||
|
opacity: 0,
|
||||||
|
blend: 'Normal',
|
||||||
|
fxSlots: [{ name: 'Color Grade', state: 'active' }, { name: 'Strobe Mask', state: 'error' }],
|
||||||
|
control: { sources: ['web'], owner: 'Web-Operator' },
|
||||||
|
loopMode: 'Once',
|
||||||
|
generatorPhase: 0,
|
||||||
|
transform: IDENTITY_TRANSFORM,
|
||||||
|
color: IDENTITY_COLOR,
|
||||||
|
dmx: DEFAULT_DMX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'be2f3e4d-5e6f-4a7b-9cad-4d5e6f7a8b9c',
|
||||||
|
z: 1,
|
||||||
|
name: 'Intro Loop',
|
||||||
|
type: 'media',
|
||||||
|
status: 'preloaded',
|
||||||
|
sourceName: 'intro_opening.mp4',
|
||||||
|
sourceBank: 'B1·01',
|
||||||
|
target: 'Local',
|
||||||
|
timePositionSec: 0,
|
||||||
|
timeDurationSec: 45,
|
||||||
|
opacity: 100,
|
||||||
|
blend: 'Dissolve',
|
||||||
|
fxSlots: [null, null],
|
||||||
|
control: { sources: ['web'], owner: 'Web-Operator' },
|
||||||
|
loopMode: 'Once',
|
||||||
|
generatorPhase: 0,
|
||||||
|
transform: IDENTITY_TRANSFORM,
|
||||||
|
color: IDENTITY_COLOR,
|
||||||
|
dmx: DEFAULT_DMX,
|
||||||
|
},
|
||||||
|
];
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* HMS MediaEngine – Design-Tokens (PLAN.md §17.6).
|
||||||
|
*
|
||||||
|
* Zentrale, später thematisierbare Ausgangswerte.
|
||||||
|
* Zustandsfarben ausschließlich für betriebliche Bedeutung;
|
||||||
|
* Status wird nie allein über Farbe vermittelt (Icon/Text ergänzen).
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Flächen (§17.6) */
|
||||||
|
--surface-app: #0d0f12; /* fast schwarzes Anthrazit (Hintergrund) */
|
||||||
|
--surface-panel: #16181d; /* Panel-Grau */
|
||||||
|
--surface-panel-raised: #1e2128; /* Panel-Grau hell */
|
||||||
|
--surface-hover: #262a33; /* Hover-Fläche */
|
||||||
|
|
||||||
|
/* Text (§17.6) */
|
||||||
|
--text-primary: #c8ccd4; /* helles Neutralgrau */
|
||||||
|
--text-secondary: #8a8f9a; /* Sekundärgrau */
|
||||||
|
--text-emphasis: #e8eaee; /* wichtige Werte, hoher Kontrast */
|
||||||
|
|
||||||
|
/* Linien */
|
||||||
|
--border: #23262d;
|
||||||
|
--border-strong: #2e323b;
|
||||||
|
|
||||||
|
/* Akzent: kühles Blau für Selektion/Fokus (§17.6) */
|
||||||
|
--accent: #4a90d9;
|
||||||
|
--accent-dim: rgba(74, 144, 217, 0.16);
|
||||||
|
--accent-line: rgba(74, 144, 217, 0.45);
|
||||||
|
|
||||||
|
/* Zustandsfarben (§17.6) – keine Deko-Farben */
|
||||||
|
--state-green: #3fa34d;
|
||||||
|
--state-yellow: #d4a017;
|
||||||
|
--state-orange: #d4641e;
|
||||||
|
--state-red: #c92a2a;
|
||||||
|
--state-blue: #4a90d9;
|
||||||
|
|
||||||
|
/* Spacing: 4-px-Raster (§17.6) */
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
|
||||||
|
/* Radien: 2–4 px, keine Pillen (§17.6) */
|
||||||
|
--radius-sm: 2px;
|
||||||
|
--radius-md: 4px;
|
||||||
|
|
||||||
|
/* Typografie (§17.6) */
|
||||||
|
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
|
||||||
|
--fs-xs: 11px;
|
||||||
|
--fs-sm: 12px;
|
||||||
|
--fs-md: 13px;
|
||||||
|
--fs-lg: 14px;
|
||||||
|
|
||||||
|
/* Animationen: 100–180 ms, keine dekorativen Animationen (§17.6) */
|
||||||
|
--dur-fast: 100ms;
|
||||||
|
--dur-med: 180ms;
|
||||||
|
--ease: ease-out;
|
||||||
|
|
||||||
|
/* Layout-Konstanten des Workspace (§17.2) */
|
||||||
|
--bar-height: 32px; /* obere Statusleiste */
|
||||||
|
--footer-height: 24px; /* Fußleiste */
|
||||||
|
--toolbar-width: 48px; /* linke Werkzeugleiste */
|
||||||
|
--toolbar-rail: 16px; /* eingeklappte Werkzeugleiste */
|
||||||
|
--sidebar-width: 264px; /* rechte Seitenleiste */
|
||||||
|
--row-height: 30px; /* Layer-Zeilen, Desktop-Density 28–32 px */
|
||||||
|
--inspector-default: 264px; /* Inspector-Grundhöhe (~30 % bei 900 px) */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Spaltenraster der Layer-Tabelle (§17.3):
|
||||||
|
* Status | Layer | Typ | Source | Target | Time | Opacity | Blend | FX | Control
|
||||||
|
*/
|
||||||
|
--lt-cols: 24px minmax(150px, 1.6fr) 84px minmax(130px, 1.4fr) minmax(108px, 1fr)
|
||||||
|
104px 132px 88px 92px 96px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* Domain- und API-Typen der Weboberfläche.
|
||||||
|
*
|
||||||
|
* Spiegel der Verträge aus apps/control_server/hms_control_server/app.py
|
||||||
|
* sowie PLAN.md §10 (Domäne), §17 (Browseroberfläche) und §23 (API).
|
||||||
|
* Der Server bleibt autoritativ; der Client synchronisiert nur Zustand (§3.2, §17.9).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ---------- Layer-Domäne (§17.3) ---------- */
|
||||||
|
|
||||||
|
/** Zustände, die §17.10 visuell eindeutig unterscheidbar verlangt. */
|
||||||
|
export type LayerStatus =
|
||||||
|
| 'active'
|
||||||
|
| 'paused'
|
||||||
|
| 'preloaded'
|
||||||
|
| 'warning'
|
||||||
|
| 'error'
|
||||||
|
| 'offline'
|
||||||
|
| 'out_of_sync';
|
||||||
|
|
||||||
|
export type LayerType = 'media' | 'generator' | 'adjustment' | 'group';
|
||||||
|
|
||||||
|
export type FxSlotState = 'active' | 'bypassed' | 'error';
|
||||||
|
|
||||||
|
export interface FxSlot {
|
||||||
|
readonly name: string;
|
||||||
|
readonly state: FxSlotState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ControlSourceTag = 'web' | 'artnet' | 'timeline' | 'automation';
|
||||||
|
|
||||||
|
export interface ControlInfo {
|
||||||
|
readonly sources: readonly ControlSourceTag[];
|
||||||
|
readonly owner: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransformParams {
|
||||||
|
readonly x: number; // px
|
||||||
|
readonly y: number; // px
|
||||||
|
readonly scale: number; // %
|
||||||
|
readonly rotation: number; // Grad
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColorParams {
|
||||||
|
readonly brightness: number; // %
|
||||||
|
readonly contrast: number; // %
|
||||||
|
readonly saturation: number; // %
|
||||||
|
readonly hue: number; // Grad
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DmxInfo {
|
||||||
|
readonly universe: number; // 0..63
|
||||||
|
readonly address: number; // 1..512, 16-Bit-Kanäle folgen §16
|
||||||
|
readonly mode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Layer {
|
||||||
|
/** UUID – Bestandteil des Parameterpfads (§10.2), nie der sichtbare Name. */
|
||||||
|
readonly id: string;
|
||||||
|
readonly z: number;
|
||||||
|
readonly name: string;
|
||||||
|
readonly type: LayerType;
|
||||||
|
readonly status: LayerStatus;
|
||||||
|
readonly sourceName: string;
|
||||||
|
/** Bank/Index-Kurzform wie „B1·04“, „GEN“, „GRP“ oder „ADJ“. */
|
||||||
|
readonly sourceBank: string;
|
||||||
|
readonly target: string;
|
||||||
|
readonly timePositionSec: number;
|
||||||
|
readonly timeDurationSec: number; // 0 = unbegrenzt (∞)
|
||||||
|
readonly opacity: number; // 0..100 (UI); die Engine speichert 0..1
|
||||||
|
readonly blend: string;
|
||||||
|
readonly fxSlots: readonly [FxSlot | null, FxSlot | null];
|
||||||
|
readonly control: ControlInfo;
|
||||||
|
readonly loopMode: string;
|
||||||
|
readonly generatorPhase: number; // s, nur Generatoren
|
||||||
|
readonly transform: TransformParams;
|
||||||
|
readonly color: ColorParams;
|
||||||
|
readonly dmx: DmxInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feingranulare Änderungen an einem Layer (Tabelle/Inspector). */
|
||||||
|
export interface LayerPatch {
|
||||||
|
readonly name?: string;
|
||||||
|
readonly opacity?: number;
|
||||||
|
readonly blend?: string;
|
||||||
|
readonly loopMode?: string;
|
||||||
|
readonly timeDurationSec?: number;
|
||||||
|
readonly generatorPhase?: number;
|
||||||
|
readonly transform?: TransformParams;
|
||||||
|
readonly color?: ColorParams;
|
||||||
|
readonly dmx?: DmxInfo;
|
||||||
|
readonly fxSlots?: readonly [FxSlot | null, FxSlot | null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- REST-Vertrag (§23.2, app.py) ---------- */
|
||||||
|
|
||||||
|
export interface HealthResponse {
|
||||||
|
readonly status: string;
|
||||||
|
readonly phase: number;
|
||||||
|
readonly node_id: string;
|
||||||
|
readonly revision: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentityResponse {
|
||||||
|
readonly node_id: string;
|
||||||
|
readonly display_name: string;
|
||||||
|
readonly roles: readonly string[];
|
||||||
|
readonly renders_locally: boolean;
|
||||||
|
readonly is_coordinator: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeHealth = 'online' | 'degraded' | 'stale' | 'offline';
|
||||||
|
|
||||||
|
export interface ClusterNode {
|
||||||
|
readonly node_id: string;
|
||||||
|
readonly display_name: string;
|
||||||
|
readonly roles: readonly string[];
|
||||||
|
readonly health: NodeHealth;
|
||||||
|
readonly category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClusterNodesResponse {
|
||||||
|
readonly self: string;
|
||||||
|
readonly nodes: readonly ClusterNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParametersResponse {
|
||||||
|
readonly revision: number;
|
||||||
|
readonly values: Readonly<Record<string, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosticsResponse {
|
||||||
|
readonly renderer: string;
|
||||||
|
readonly artnet: string;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly node_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandActor {
|
||||||
|
readonly type: string;
|
||||||
|
readonly id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParameterSetPayload {
|
||||||
|
readonly parameter_path: string;
|
||||||
|
readonly value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Command-Envelope §23.2: command_id, type, expected_revision, actor, payload. */
|
||||||
|
export interface CommandEnvelope {
|
||||||
|
readonly command_id: string;
|
||||||
|
readonly type: string;
|
||||||
|
readonly expected_revision: number | null;
|
||||||
|
readonly actor: CommandActor;
|
||||||
|
readonly payload: ParameterSetPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandAck {
|
||||||
|
readonly status: string;
|
||||||
|
readonly command_id: string;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly effective: number;
|
||||||
|
readonly duplicate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseResponse {
|
||||||
|
readonly status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- WebSocket-Vertrag (§17.9, app.py /ws) ---------- */
|
||||||
|
|
||||||
|
export interface WsSnapshot {
|
||||||
|
readonly type: 'snapshot';
|
||||||
|
readonly revision: number;
|
||||||
|
readonly values: Readonly<Record<string, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsParameterUpdate {
|
||||||
|
readonly type: 'parameter.update';
|
||||||
|
readonly parameter_path: string;
|
||||||
|
readonly value: number;
|
||||||
|
readonly revision: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsHeartbeat {
|
||||||
|
readonly type: 'heartbeat';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WsServerEvent = WsSnapshot | WsParameterUpdate | WsHeartbeat;
|
||||||
|
|
||||||
|
/* ---------- UI-Zustände (§17.2, §17.4, §17.5) ---------- */
|
||||||
|
|
||||||
|
export type AlertSeverity = 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export interface AlertItem {
|
||||||
|
readonly id: number;
|
||||||
|
readonly timeMs: number;
|
||||||
|
readonly severity: AlertSeverity;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SidebarTab = 'active' | 'nodes' | 'alerts';
|
||||||
|
|
||||||
|
export type InspectorTab = 'layer' | 'source' | 'transform' | 'color' | 'fx' | 'dmx' | 'advanced';
|
||||||
|
|
||||||
|
export type ToolId =
|
||||||
|
| 'mixer'
|
||||||
|
| 'media'
|
||||||
|
| 'effects'
|
||||||
|
| 'presets'
|
||||||
|
| 'outputs'
|
||||||
|
| 'network'
|
||||||
|
| 'plugins'
|
||||||
|
| 'diagnostics';
|
||||||
|
|
||||||
|
export type OpMode = 'setup' | 'live';
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Formatierungs- und Mapping-Helfer der Weboberfläche.
|
||||||
|
* Zeitkritische Werte werden tabellarisch monospaced dargestellt (§17.6).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LayerStatus, LayerType } from './types';
|
||||||
|
|
||||||
|
/** Klassennamen kompakt zusammenführen; leere/false-Teile entfallen. */
|
||||||
|
export function cx(...parts: ReadonlyArray<string | false | null | undefined>): string {
|
||||||
|
return parts.filter((p): p is string => typeof p === 'string' && p.length > 0).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sekunden → „M:SS.t“ (tabellarisch, Zehntelsekunden). */
|
||||||
|
export function formatTimecode(totalSec: number): string {
|
||||||
|
const safe = Number.isFinite(totalSec) && totalSec >= 0 ? totalSec : 0;
|
||||||
|
const minutes = Math.floor(safe / 60);
|
||||||
|
const seconds = Math.floor(safe % 60);
|
||||||
|
const tenth = Math.floor((safe % 1) * 10);
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}.${tenth}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uhrzeit HH:MM:SS (lokal). */
|
||||||
|
export function formatClock(date: Date): string {
|
||||||
|
const h = date.getHours().toString().padStart(2, '0');
|
||||||
|
const m = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
const s = date.getSeconds().toString().padStart(2, '0');
|
||||||
|
return `${h}:${m}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimeMs(ms: number): string {
|
||||||
|
return formatClock(new Date(ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FPS mit zwei Dezimalstellen (§17.2); null = keine Messung. */
|
||||||
|
export function formatFps(v: number | null): string {
|
||||||
|
return v === null ? '—' : v.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deutsche Zahleneingabe: „12,5“ → 12.5; null wenn nicht parsebar. */
|
||||||
|
export function parseNumberInput(raw: string): number | null {
|
||||||
|
const trimmed = raw.trim().replace(',', '.');
|
||||||
|
if (trimmed === '') return null;
|
||||||
|
const parsed = Number(trimmed);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorMessage(err: unknown): string {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return 'unbekannter Fehler';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opacity: Engine speichert 0..1, die UI zeigt 0..100. */
|
||||||
|
export function engineToOpacity(v: number): number {
|
||||||
|
return clamp(Math.round(v * 100), 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function opacityToEngine(v: number): number {
|
||||||
|
return clamp(v, 0, 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text-Label je Zustand – Status nie allein über Farbe (§17.6). */
|
||||||
|
export const STATUS_LABEL: Record<LayerStatus, string> = {
|
||||||
|
active: 'Aktiv',
|
||||||
|
paused: 'Pausiert',
|
||||||
|
preloaded: 'Vorgeladen',
|
||||||
|
warning: 'Warnung',
|
||||||
|
error: 'Fehler',
|
||||||
|
offline: 'Offline',
|
||||||
|
out_of_sync: 'Nicht synchron',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Farbtöne je Zustand (ergänzt durch Glyphe/Text, nie allein). */
|
||||||
|
export const STATUS_TONE: Record<LayerStatus, string> = {
|
||||||
|
active: 'st-active',
|
||||||
|
paused: 'st-paused',
|
||||||
|
preloaded: 'st-preloaded',
|
||||||
|
warning: 'st-warning',
|
||||||
|
error: 'st-error',
|
||||||
|
offline: 'st-offline',
|
||||||
|
out_of_sync: 'st-out-of-sync',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TYPE_LABEL: Record<LayerType, string> = {
|
||||||
|
media: 'Media',
|
||||||
|
generator: 'Generator',
|
||||||
|
adjustment: 'Adjustment',
|
||||||
|
group: 'Group',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BLEND_MODES: readonly string[] = [
|
||||||
|
'Normal',
|
||||||
|
'Add',
|
||||||
|
'Screen',
|
||||||
|
'Multiply',
|
||||||
|
'Dissolve',
|
||||||
|
'Lighten',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LOOP_MODES: readonly string[] = ['Loop', 'Once', 'Ping-Pong', 'Hold'];
|
||||||
|
|
||||||
|
/** Parameterpfad §10.2 (packages/parameter_engine/hms_parameter/paths.py). */
|
||||||
|
export function layerOpacityPath(compositionId: string, layerId: string): string {
|
||||||
|
return `composition/${compositionId}/layer/${layerId}/opacity`;
|
||||||
|
}
|
||||||
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typisierte Vite-Umgebungsvariablen (apps/web).
|
||||||
|
*
|
||||||
|
* VITE_WS_URL: optionale direkte WebSocket-URL zum Control Core, z. B.
|
||||||
|
* „ws://localhost:8000/ws“, wenn die Oberfläche nicht über den
|
||||||
|
* Same-Origin-Proxy (Vite-Dev-Server/Reverse-Proxy) läuft.
|
||||||
|
*/
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_WS_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":"7.0.2","root":["./src/App.tsx","./src/icons.tsx","./src/main.tsx","./src/seed.ts","./src/types.ts","./src/utils.ts","./src/vite-env.d.ts","./src/components/Inspector.tsx","./src/components/LayerTable.tsx","./src/components/RightSidebar.tsx","./src/components/StatusBar.tsx","./src/hooks/useApi.ts","./src/hooks/useWebSocket.ts"],"packageJsons":["./node_modules/.pnpm/@types+react-dom@19.3.0_@types+react@19.3.0/node_modules/@types/react-dom/package.json","./node_modules/.pnpm/@types+react@19.3.0/node_modules/@types/react/package.json","./node_modules/.pnpm/csstype@3.2.3/node_modules/csstype/package.json","./node_modules/.pnpm/react-dom@19.3.0_react@19.3.0/node_modules/react-dom/package.json","./node_modules/.pnpm/react@19.3.0/node_modules/react/package.json","./node_modules/.pnpm/vite@8.2.2/node_modules/vite/package.json","./package.json"],"missingPackageJsons":["./node_modules/.pnpm/@types+react-dom@19.3.0_@types+react@19.3.0/node_modules/react/package.json","./node_modules/.pnpm/vite@8.2.2/node_modules/@types/node/package.json","./node_modules/.pnpm/vite@8.2.2/node_modules/@vitejs/devtools/package.json","./node_modules/.pnpm/vite@8.2.2/node_modules/esbuild/package.json","./node_modules/.pnpm/vite@8.2.2/node_modules/jiti/package.json","./node_modules/.pnpm/vite@8.2.2/node_modules/less/package.json","./node_modules/.pnpm/vite@8.2.2/node_modules/sass-embedded/package.json","./node_modules/.pnpm/vite@8.2.2/node_modules/sass/package.json","./node_modules/.pnpm/vite@8.2.2/node_modules/stylus/package.json","./node_modules/.pnpm/vite@8.2.2/node_modules/sugarss/package.json","./node_modules/.pnpm/vite@8.2.2/node_modules/terser/package.json","./node_modules/.pnpm/vite@8.2.2/node_modules/tsx/package.json","./node_modules/.pnpm/vite@8.2.2/node_modules/vite/types/package.json","./node_modules/.pnpm/vite@8.2.2/node_modules/yaml/package.json","./node_modules/@types/react-dom/client/package.json","./node_modules/@types/react/jsx-runtime/package.json","./node_modules/@types/vite/client/package.json","./node_modules/react-dom/client/package.json","./node_modules/react/jsx-runtime/package.json","./node_modules/vite/client/package.json"]}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vite-Konfiguration der HMS-MediaEngine-Weboberfläche (§17).
|
||||||
|
*
|
||||||
|
* Dev-Server-Proxy auf den Control Core (apps/control_server, Port 8000):
|
||||||
|
* - '/api' als HTTP-Proxy
|
||||||
|
* - '/ws' als WebSocket-Proxy (Snapshot + Deltas, §17.9)
|
||||||
|
* Same-Origin im Dev-Betrieb; produktiv übernimmt der Launcher/Reverse-Proxy.
|
||||||
|
*/
|
||||||
|
const proxy = {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:8000',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 4173,
|
||||||
|
proxy,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# ADR-0006: Frontend – React mit Vite
|
||||||
|
|
||||||
|
- **Status:** Angenommen
|
||||||
|
- **Datum:** 2026-09-11
|
||||||
|
- **Phase:** 5
|
||||||
|
- **Bauplan:** §7 („TypeScript, React oder Svelte, Vite-basierter Build“), §32 ADR-Pflicht
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
**React 18 mit Vite** im statischen SPA-Modus.
|
||||||
|
|
||||||
|
## Begründung
|
||||||
|
|
||||||
|
- Größtes Ökosystem an Komponenten, Tastatur- und Drag-Bibliotheken – wichtig für dichte Layer-Tabellen mit Ziehwerten (§17.3), Drag-Sortierung, undockbare Paneele (§17.2)
|
||||||
|
- Breitere Verfügbarkeit erfahrener Frontend-Entwickler
|
||||||
|
- Vite-Build für statische Assets passt zu §3.2 (Web-UI ist nie Videoausgang)
|
||||||
|
- Svelte wäre kleiner – aber bei einer professionellen Arbeitsoberfläche mit komplexem State (100+ Parameter je Layer) überwiegt Reacts Vorhersagbarkeit
|
||||||
|
- Ein Wechsel ist durch die saubere REST/WebSocket-API-Trennung (§23) jederzeit ohne Backend-Änderung möglich
|
||||||
|
|
||||||
|
## Alternativen
|
||||||
|
|
||||||
|
- Svelte/SvelteKit: kompakter, aber kleineres Ökosystem; bei tabellenlastiger UI weniger Vorteile
|
||||||
|
|
||||||
|
## Folgen
|
||||||
|
|
||||||
|
- pnpm-Lockfile; statische Auslieferung über FastAPI (web/-Ordner, §9)
|
||||||
|
- Vitest + Playwright für Tests (§7)
|
||||||
|
- Design-Tokens zentral in CSS-Variablen (§17.6), kein UI-Framework wie MUI – eigene dichte Oberfläche
|
||||||
|
|
||||||
|
## Freigabe
|
||||||
|
|
||||||
|
- Standardumsetzung gemäß §7; ADR dokumentiert die Auswahl.
|
||||||
Reference in New Issue
Block a user