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:
HMS MediaEngine Agent
2026-09-11 03:02:38 +02:00
parent 423bd00b4e
commit c6a3cf7751
22 changed files with 4800 additions and 0 deletions
+30
View File
@@ -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>
+22
View File
@@ -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"
}
}
+730
View File
@@ -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
+605
View File
@@ -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>
);
}
+484
View File
@@ -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>
);
}
+289
View File
@@ -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>
);
}
+229
View File
@@ -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>
);
}
+92
View File
@@ -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>
);
}
+122
View File
@@ -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' }),
});
}
+117
View File
@@ -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 };
}
+278
View File
@@ -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>
);
+16
View File
@@ -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>,
);
+274
View File
@@ -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
+78
View File
@@ -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: 24 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: 100180 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 2832 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;
}
+216
View File
@@ -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';
+108
View File
@@ -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`;
}
+16
View File
@@ -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;
}
+21
View File
@@ -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"]
}
+1
View File
@@ -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"]}
+32
View File
@@ -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,
},
});
+32
View File
@@ -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.