diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..edc594f --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,30 @@ + + + + + + + HMS MediaEngine + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..02127ab --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml new file mode 100644 index 0000000..1e5048b --- /dev/null +++ b/apps/web/pnpm-lock.yaml @@ -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 diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..225ac04 --- /dev/null +++ b/apps/web/src/App.tsx @@ -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; +} + +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(SEED_LAYERS); + const [selectedId, setSelectedId] = useState( + SEED_LAYERS.length > 0 ? SEED_LAYERS[0].id : null, + ); + const [cursorId, setCursorId] = useState( + SEED_LAYERS.length > 0 ? SEED_LAYERS[0].id : null, + ); + + const [tool, setTool] = useState('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('layer'); + const [sidebarVisible, setSidebarVisible] = useState(true); + const [sidebarTab, setSidebarTab] = useState('active'); + const [opMode, setOpMode] = useState('setup'); + const liveLock = opMode === 'live'; + + /* ---------- Meldungen ---------- */ + + const [alerts, setAlerts] = useState([]); + 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(null); + const [nodes, setNodes] = useState([]); + const [selfNodeId, setSelfNodeId] = useState(null); + const [diagnostics, setDiagnostics] = useState(null); + const [health, setHealth] = useState(null); + + useEffect(() => { + let disposed = false; + const poll = async (): Promise => { + 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(() => new Date()); + useEffect(() => { + const t = window.setInterval(() => setNow(new Date()), 1000); + return () => window.clearInterval(t); + }, []); + + const [uiFps, setUiFps] = useState(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(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(() => { + 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( + () => mergedLayers.find(l => l.id === selectedId) ?? null, + [mergedLayers, selectedId], + ); + + /* ---------- Opacity: lokales Feedback + serverbestätigter Commit ---------- */ + + const dragOriginRef = useRef>(new Map()); + const lastOpacityCmdRef = useRef>(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 => { + 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 => { + 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): 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(() => { + 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(() => { + 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( + () => + 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>(() => new Map(), []); + const artnetSenders = useMemo>(() => new Map(), []); + + /* ---------- Render ---------- */ + + return ( +
+ +
+ +
+ { + void handleOpacityCommit(id, value); + }} + onEnterPressed={() => { + if (inspectorCollapsed) setInspectorCollapsed(false); + }} + /> + setInspectorCollapsed(v => !v)} + onResizeStart={handleInspectorResizeStart} + resizing={inspectorResizing} + onPatch={applyPatch} + onReleaseOpacity={id => { + void handleReleaseOpacity(id); + }} + /> +
+ {sidebarVisible && ( + { + setSelectedId(id); + setCursorId(id); + }} + onClearAlerts={() => setAlerts([])} + nodeFps={nodeFps} + artnetSenders={artnetSenders} + /> + )} +
+
+ + + {mergedLayers.length} Layer{selectedLayer !== null ? ' · 1 ausgewählt' : ''} + + {warnTotal > 0 && ( + + + {warnTotal} Warnung{warnTotal !== 1 ? 'en' : ''} + + )} + {errTotal > 0 && ( + + + {errTotal} Fehler + + )} + + + {ws.connected ? `Sync · Revision ${ws.revision ?? '—'}` : 'Getrennt'} + + {health !== null && ( + + Core {health.status} + + )} + + + +
+
+ ); +} diff --git a/apps/web/src/components/Inspector.tsx b/apps/web/src/components/Inspector.tsx new file mode 100644 index 0000000..856f4f8 --- /dev/null +++ b/apps/web/src/components/Inspector.tsx @@ -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) => 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(null); + const inputRef = useRef(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): void => { + if (disabled) return; + e.preventDefault(); + drag.current = { startValue: value, startX: e.clientX }; + (e.target as HTMLSpanElement).setPointerCapture(e.pointerId); + }; + + const onGripMove = (e: ReactPointerEvent): 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 ( +
+ {label} +
+ 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 && {unit}} + { + e.stopPropagation(); + if (e.key === 'ArrowUp') nudge(e.shiftKey ? step / 10 : step); + if (e.key === 'ArrowDown') nudge(e.shiftKey ? -(step / 10) : -step); + }} + > + ⇄ + +
+
+ ); +} + +/* ---------------------------------------------------------------- + * Kleine Bausteine + * ---------------------------------------------------------------- */ + +function Field({ label, children }: { readonly label: string; readonly children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function FieldValue({ label, value, title }: { readonly label: string; readonly value: string; readonly title?: string }) { + return ( + + + {value} + + + ); +} + +function Group({ title, children }: { readonly title: string; readonly children: ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +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 ( + + + onPatch({ name: e.target.value })} + /> + + + + + + + + + + + ); +} + +function SourceTabBody({ layer, onPatch }: TabBodyProps) { + return ( + <> + + + + + + + onPatch({ timeDurationSec: Math.max(0, v) })} + /> + + {layer.type === 'generator' && ( + onPatch({ generatorPhase: v })} + /> + )} + + + ); +} + +function TransformTabBody({ layer, onPatch }: TabBodyProps) { + return ( + + onPatch({ transformX: v })} /> + onPatch({ transformY: v })} /> + onPatch({ transformScale: v })} /> + onPatch({ transformRotation: v })} /> + + ); +} + +function ColorTabBody({ layer, onPatch }: TabBodyProps) { + return ( + + onPatch({ colorBrightness: v })} /> + onPatch({ colorContrast: v })} /> + onPatch({ colorSaturation: v })} /> + onPatch({ colorHue: v })} /> + + ); +} + +function FxTabBody({ layer, onPatch }: TabBodyProps) { + return ( + + {layer.fxSlots.map((slot, idx) => ( +
+ Slot {idx + 1} + {slot === null ? ( + + leer + + ) : ( + + {slot.name} + + {slot.state === 'error' && ERR} + + )} +
+ ))} +
+ ); +} + +function DmxTabBody({ layer, liveLock, onPatch }: TabBodyProps) { + return ( + <> + + onPatch({ dmxUniverse: Math.max(0, Math.min(63, Math.round(v))) })} + /> + onPatch({ dmxAddress: Math.max(1, Math.min(512, Math.round(v))) })} + /> + + +

+ DMX-Werte sind numerisch präzise editierbar (16 Bit). Patch-Änderungen erfordern + Setup-Modus und werden serverseitig bestätigt. +

+ + ); +} + +function AdvancedTabBody({ layer, onReleaseOpacity }: TabBodyProps) { + return ( + + + + +
+ Override +
+ +
+
+
+ ); +} + +const TAB_BODIES: Record> = { + 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 ( +
+ + Inspector: {layer !== null ? layer.name : 'keine Auswahl'} +
+ ); + } + + return ( +
+
+
+ {TABS.map((t) => ( + + ))} +
+ {layer !== null && ( + + + + {layer.name} + + + )} + +
+
+
+ {layer === null ? ( +
Kein Layer ausgewählt – Zeile in der Tabelle wählen.
+ ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/LayerTable.tsx b/apps/web/src/components/LayerTable.tsx new file mode 100644 index 0000000..3b14d31 --- /dev/null +++ b/apps/web/src/components/LayerTable.tsx @@ -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(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): 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 ( +
+
+ {HEADERS.map((h, i) => ( +
+ {h} +
+ ))} +
+ {layers.length === 0 ? ( +
Keine Layer im Projekt.
+ ) : ( + 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 ( +
{ + props.onSelect(layer.id); + props.onCursor(layer.id); + }} + > + {/* Status – Glyphe + Ton + Tooltip, nie Farbe allein (§17.6) */} +
+ + {STATUS_LABEL[layer.status]} +
+ {/* Layer: Z-Reihenfolge + editierbarer Name */} +
+ + {layer.z} + + {editing ? ( + setDraftName(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Enter') commitEdit(); + else if (e.key === 'Escape') setEditingId(null); + }} + onBlur={commitEdit} + /> + ) : ( + beginEdit(layer)} + > + {layer.name} + + )} +
+ {/* Typ */} +
+ {TYPE_LABEL[layer.type]} +
+ {/* Source: Clip-/Generatorname + Bank/Index */} +
+ {layer.sourceName} + {layer.sourceBank} +
+ {/* Target */} +
+ {layer.target} +
+ {/* Time – monospace/tabellarisch (§17.6) */} +
+ {formatTimecode(layer.timePositionSec)} + {dur} +
+ {/* Opacity: Wert + schmaler Fader (64 px) */} +
+ {op} + e.stopPropagation()} + onChange={(e) => handleOpacityInput(layer, Number(e.target.value))} + onPointerUp={() => commitOpacity(layer)} + onKeyUp={() => commitOpacity(layer)} + onBlur={() => commitOpacity(layer)} + /> +
+ {/* Blend */} +
+ {layer.blend} +
+ {/* FX: zwei Slots mit Bypass-/Fehlerstatus */} +
+ {layer.fxSlots.map((slot, idx) => + slot === null ? ( + + — + + ) : ( + + + {slot.name} + {slot.state === 'bypassed' && BYP} + {slot.state === 'error' && ERR} + + ), + )} +
+ {/* Control: Quellen + Owner */} +
+ {layer.control.sources.join(' · ')} + {layer.control.owner} +
+
+ ); + }) + )} +
+ ); +} diff --git a/apps/web/src/components/RightSidebar.tsx b/apps/web/src/components/RightSidebar.tsx new file mode 100644 index 0000000..eee19a8 --- /dev/null +++ b/apps/web/src/components/RightSidebar.tsx @@ -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; + readonly artnetSenders: ReadonlyMap; +} + +const HEALTH_TONE: Record = { + online: 'tone-ok', + degraded: 'tone-warn', + stale: 'tone-warn', + offline: 'tone-bad', +}; + +const HEALTH_LABEL: Record = { + online: 'online', + degraded: 'degraded', + stale: 'veraltet', + offline: 'offline', +}; + +function SeverityIcon({ severity }: { readonly severity: AlertItem['severity'] }) { + if (severity === 'error') return ; + if (severity === 'warning') return ; + return ; +} + +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
Keine laufenden oder vorgeladenen Layer.
; + } + return ( + <> + {active.map((l) => ( +
props.onSelectLayer(l.id)} + > +
+ {l.sourceBank.slice(0, 2)} +
+
+
+ + + + + {l.name} + +
+
+ {formatTimecode(l.timePositionSec)} + + {l.target} + + {STATUS_LABEL[l.status]} +
+
+ + +
+
+
+ ))} + + ); +} + +function NodesTab(props: RightSidebarProps) { + if (props.nodes.length === 0) { + return
Keine Nodes registriert (Control Core nicht erreichbar).
; + } + return ( + <> + {props.nodes.map((n) => { + const fps = props.nodeFps.get(n.node_id); + const sender = props.artnetSenders.get(n.node_id); + return ( +
+
+
+ + + {n.display_name} + + {props.selfNodeId === n.node_id && ( + + (dieser) + + )} +
+
+ + {n.roles.map((r) => ( + + {r} + + ))} + + + {HEALTH_LABEL[n.health]} + +
+
+ + FPS {fps !== undefined ? fps.toFixed(2) : '—'} + + + Art-Net {sender ?? '—'} + +
+
+
+ ); + })} + + ); +} + +function AlertsTab(props: RightSidebarProps) { + if (props.alerts.length === 0) { + return
Keine Meldungen.
; + } + return ( + <> + {props.alerts.map((a) => ( +
+ + + + {formatTimeMs(a.timeMs)} + {a.message} +
+ ))} + + ); +} + +export default function RightSidebar(props: RightSidebarProps) { + return ( + + ); +} diff --git a/apps/web/src/components/StatusBar.tsx b/apps/web/src/components/StatusBar.tsx new file mode 100644 index 0000000..15333a6 --- /dev/null +++ b/apps/web/src/components/StatusBar.tsx @@ -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 = { + 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 ( +
+ HMS MediaEngine + + + {projectName} + + + + Node + {nodeName} + +
+ + + {output.label} + + + Quality: + {quality} + + + FPS + {formatFps(fps)} + + + + {artnet.label} + + + + {sync.label} + + + + {clock} + +
+
+ ); +} diff --git a/apps/web/src/hooks/useApi.ts b/apps/web/src/hooks/useApi.ts new file mode 100644 index 0000000..e5cd75f --- /dev/null +++ b/apps/web/src/hooks/useApi.ts @@ -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(path: string, init?: RequestInit): Promise { + 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 => request('/system/health'); + +export const getIdentity = (): Promise => request('/system/identity'); + +export const getClusterNodes = (): Promise => + request('/cluster/nodes'); + +export const getParameters = (): Promise => request('/parameters'); + +export const getDiagnostics = (): Promise => + request('/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 { + 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('/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 { + return request(`/commands/${encodeURIComponent(commandId)}/release`, { + method: 'POST', + body: JSON.stringify({ source: 'web' }), + }); +} diff --git a/apps/web/src/hooks/useWebSocket.ts b/apps/web/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..0f31e6c --- /dev/null +++ b/apps/web/src/hooks/useWebSocket.ts @@ -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>; + readonly lastEventMs: number | null; +} + +export function useWebSocket(): WsState { + const [connected, setConnected] = useState(false); + const [revision, setRevision] = useState(null); + const [values, setValues] = useState>({}); + const [lastEventMs, setLastEventMs] = useState(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) => { + 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 }; +} diff --git a/apps/web/src/icons.tsx b/apps/web/src/icons.tsx new file mode 100644 index 0000000..9ef3500 --- /dev/null +++ b/apps/web/src/icons.tsx @@ -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 { + readonly size?: number; +} + +function Svg({ size = 16, children, ...rest }: IconProps & { readonly children: ReactNode }) { + return ( + + ); +} + +/* ---------- Werkzeugleiste (§17.2) ---------- */ + +export const IconMixer = (p: IconProps) => ( + + + + + + +); + +export const IconMedia = (p: IconProps) => ( + + + + +); + +export const IconEffects = (p: IconProps) => ( + + + +); + +export const IconPresets = (p: IconProps) => ( + + + + + + +); + +export const IconOutputs = (p: IconProps) => ( + + + + +); + +export const IconNetwork = (p: IconProps) => ( + + + + + + +); + +export const IconPlugins = (p: IconProps) => ( + + + + +); + +export const IconDiagnostics = (p: IconProps) => ( + + + +); + +/* ---------- Layer-Zustandsglyphen (§17.3, §17.10) ---------- */ + +export const IconStActive = (p: IconProps) => ( + + + +); + +export const IconStPaused = (p: IconProps) => ( + + + +); + +export const IconStPreloaded = (p: IconProps) => ( + + + + +); + +export const IconStWarning = (p: IconProps) => ( + + + + +); + +export const IconStError = (p: IconProps) => ( + + + + +); + +export const IconStOffline = (p: IconProps) => ( + + + + +); + +export const IconStOutOfSync = (p: IconProps) => ( + + + +); + +const STATUS_ICON: Record> = { + 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 ; +} + +/* ---------- Bedienelemente ---------- */ + +export const IconChevronDown = (p: IconProps) => ( + + + +); + +export const IconChevronUp = (p: IconProps) => ( + + + +); + +export const IconChevronLeft = (p: IconProps) => ( + + + +); + +export const IconChevronRight = (p: IconProps) => ( + + + +); + +export const IconClock = (p: IconProps) => ( + + + + +); + +export const IconSignal = (p: IconProps) => ( + + + + +); + +export const IconMonitor = (p: IconProps) => ( + + + + +); + +export const IconLock = (p: IconProps) => ( + + + + +); + +export const IconUnlock = (p: IconProps) => ( + + + + +); + +export const IconAlertTriangle = (p: IconProps) => ( + + + + +); + +export const IconCrossCircle = (p: IconProps) => ( + + + + +); + +export const IconInfoCircle = (p: IconProps) => ( + + + + +); + +export const IconPlaySm = (p: IconProps) => ( + + + +); + +export const IconPauseSm = (p: IconProps) => ( + + + +); + +export const IconStopSm = (p: IconProps) => ( + + + +); + +export const IconSidebarPanel = (p: IconProps) => ( + + + + +); + +export const IconInspectorPanel = (p: IconProps) => ( + + + + +); + +export const IconGrip = (p: IconProps) => ( + + + +); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..106fed4 --- /dev/null +++ b/apps/web/src/main.tsx @@ -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( + + + , +); diff --git a/apps/web/src/seed.ts b/apps/web/src/seed.ts new file mode 100644 index 0000000..6b42c4d --- /dev/null +++ b/apps/web/src/seed.ts @@ -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, + }, +]; diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css new file mode 100644 index 0000000..49946a7 --- /dev/null +++ b/apps/web/src/styles/app.css @@ -0,0 +1,1008 @@ +/* + * HMS MediaEngine – Workspace-Layout und Komponentenstile. + * Dichte Desktop-Arbeitsoberfläche statt Karten (§17.1–§17.6). + * Alle Werte kommen aus tokens.css; hier nur Struktur und Zustände. + */ + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; +} + +body { + font-family: var(--font-ui); + font-size: var(--fs-md); + color: var(--text-primary); + background: var(--surface-app); + overflow: hidden; /* kein Seiten-Scroll (§17.10) */ + -webkit-font-smoothing: antialiased; +} + +button { + font: inherit; + color: inherit; + background: none; + border: none; + padding: 0; + cursor: pointer; +} + +input, +select, +textarea { + font: inherit; + color: var(--text-primary); + background: var(--surface-app); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 2px 6px; + height: 22px; +} + +input:disabled, +select:disabled { + color: var(--text-secondary); + opacity: 0.7; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +[tabindex]:focus-visible { + outline: 1px solid var(--accent); + outline-offset: -1px; +} + +.mono { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; /* Zeit/DMX/FPS/Sync §17.6 */ +} + +.ellip { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* Dünne, dunkle Scrollbalken (interner Bereichs-Scroll, kein Gesamt-Scroll) */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: var(--radius-sm); +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +/* ------------------------------------------------------------------ + * Workspace-Grid (§17.2) + * ------------------------------------------------------------------ */ + +.workspace { + display: grid; + grid-template-rows: var(--bar-height) minmax(0, 1fr) var(--footer-height); + width: 100vw; + height: 100vh; + overflow: hidden; +} + +.main { + display: flex; + min-width: 0; + min-height: 0; +} + +.center { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +/* ------------------------------------------------------------------ + * Obere Statusleiste (§17.2) + * ------------------------------------------------------------------ */ + +.statusbar { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 0 var(--space-3); + background: var(--surface-panel); + border-bottom: 1px solid var(--border); + font-size: var(--fs-sm); + overflow: hidden; +} + +.statusbar .product { + color: var(--text-emphasis); + font-weight: 600; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.statusbar .sep { + flex: none; + width: 1px; + height: 14px; + background: var(--border-strong); +} + +.status-item { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + white-space: nowrap; + min-width: 0; +} + +.status-item .value { + color: var(--text-primary); +} + +.status-push { + margin-left: auto; + display: flex; + align-items: center; + gap: var(--space-3); +} + +/* Zustandspunkte und Tons (nie Farbe allein: immer Text/Icon daneben) */ +.dot { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-secondary); +} + +.tone-ok { + color: var(--state-green); +} + +.tone-warn { + color: var(--state-yellow); +} + +.tone-bad { + color: var(--state-red); +} + +.tone-muted { + color: var(--text-secondary); +} + +.dot.tone-ok { + background: var(--state-green); +} + +.dot.tone-warn { + background: var(--state-yellow); +} + +.dot.tone-bad { + background: var(--state-red); +} + +.dot.tone-muted { + background: var(--text-secondary); +} + +/* ------------------------------------------------------------------ + * Linke Werkzeugleiste (§17.2), einklappbar + * ------------------------------------------------------------------ */ + +.toolbar { + flex: none; + width: var(--toolbar-width); + background: var(--surface-panel); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + align-items: center; + padding: var(--space-2) 0; + gap: 2px; + overflow: hidden; + transition: width var(--dur-med) var(--ease); +} + +.toolbar.collapsed { + width: var(--toolbar-rail); +} + +.tool-btn { + width: 36px; + height: 34px; + flex: none; + display: grid; + place-items: center; + border-radius: var(--radius-md); + color: var(--text-secondary); +} + +.tool-btn:hover { + background: var(--surface-hover); + color: var(--text-primary); +} + +.tool-btn.active { + background: var(--accent-dim); + color: var(--accent); +} + +.toolbar.collapsed .tool-btn { + display: none; +} + +.toolbar.collapsed .collapse-btn { + width: 14px; + height: 22px; +} + +.tool-spacer { + flex: 1; +} + +.toolbar .collapse-btn { + width: 36px; + height: 24px; + display: grid; + place-items: center; + border-radius: var(--radius-sm); + color: var(--text-secondary); +} + +.toolbar .collapse-btn:hover { + background: var(--surface-hover); + color: var(--text-primary); +} + +/* ------------------------------------------------------------------ + * Layer-Tabelle (§17.3) + * ------------------------------------------------------------------ */ + +.layer-table { + flex: 1; + min-height: 0; + min-width: 0; + overflow: auto; /* interner Scroll, kein Gesamt-Scroll (§17.10) */ + outline: none; +} + +.lt-head, +.lt-row { + display: grid; + grid-template-columns: var(--lt-cols); + align-items: center; +} + +.lt-head { + position: sticky; + top: 0; + z-index: 2; + height: 26px; + background: var(--surface-panel); + border-bottom: 1px solid var(--border); + font-size: var(--fs-xs); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; + user-select: none; +} + +.lt-row { + height: var(--row-height); /* Desktop-Density 28–32 px */ + border-bottom: 1px solid var(--border); + font-size: var(--fs-sm); +} + +.lt-row:hover { + background: var(--surface-hover); +} + +/* Selektion: klare Akzentfläche ohne Blinken (§17.3) */ +.lt-row.selected { + background: var(--accent-dim); + box-shadow: inset 2px 0 0 var(--accent); +} + +.lt-row.is-cursor { + box-shadow: inset 0 0 0 1px var(--accent-line); +} + +.lt-row.selected.is-cursor { + box-shadow: + inset 2px 0 0 var(--accent), + inset 0 0 0 1px var(--accent-line); +} + +.lt-cell { + display: flex; + align-items: center; + gap: 6px; + height: 100%; + min-width: 0; + padding: 0 8px; +} + +.lt-head .lt-cell { + height: 100%; + color: inherit; +} + +.lt-cell.c-status { + padding: 0; + justify-content: center; +} + +/* Status-Zustände: Farbe immer mit Icon/Text (§17.6) */ +.st-active { + color: var(--state-green); +} + +.st-paused { + color: var(--state-yellow); +} + +.st-preloaded { + color: var(--state-blue); +} + +.st-warning { + color: var(--state-orange); +} + +.st-error { + color: var(--state-red); +} + +.st-offline { + color: var(--state-red); +} + +.st-out-of-sync { + color: var(--state-orange); +} + +.z-badge { + flex: none; + font-size: var(--fs-xs); + color: var(--text-secondary); + min-width: 20px; + text-align: right; +} + +.layer-name { + color: var(--text-primary); + cursor: default; +} + +.lt-row.selected .layer-name { + color: var(--text-emphasis); +} + +.name-input { + width: 100%; + height: 20px; + font-size: var(--fs-sm); + padding: 0 4px; +} + +.src-bank { + flex: none; + font-size: var(--fs-xs); + color: var(--text-secondary); +} + +.time-pos { + color: var(--text-emphasis); +} + +.time-dur { + color: var(--text-secondary); + font-size: var(--fs-xs); +} + +/* Schmaler Opacity-Fader, 64 px (§17.3) */ +.opacity-fader { + -webkit-appearance: none; + appearance: none; + flex: none; + width: 64px; + height: 16px; + background: transparent; + border: none; + padding: 0; + cursor: ew-resize; +} + +.opacity-fader::-webkit-slider-runnable-track { + height: 2px; + background: var(--border-strong); + border-radius: var(--radius-sm); +} + +.opacity-fader::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 8px; + height: 14px; + margin-top: -6px; + background: var(--text-emphasis); + border-radius: var(--radius-sm); +} + +.opacity-fader::-moz-range-track { + height: 2px; + background: var(--border-strong); + border-radius: var(--radius-sm); +} + +.opacity-fader::-moz-range-thumb { + width: 8px; + height: 14px; + border: none; + background: var(--text-emphasis); + border-radius: var(--radius-sm); +} + +.opacity-fader:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 2px; +} + +.opacity-val { + flex: none; + width: 34px; + text-align: right; + font-size: var(--fs-xs); + color: var(--text-primary); +} + +.fx-slot { + flex: none; + display: inline-flex; + align-items: center; + gap: 3px; + height: 16px; + padding: 0 4px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + font-size: var(--fs-xs); + color: var(--text-secondary); + background: var(--surface-panel-raised); +} + +.fx-slot .fx-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--state-green); +} + +.fx-slot.bypassed { + opacity: 0.55; +} + +.fx-slot.bypassed .fx-dot { + background: var(--text-secondary); +} + +.fx-slot.error { + border-color: var(--state-red); + color: var(--state-red); +} + +.fx-slot.error .fx-dot { + background: var(--state-red); +} + +.ctrl-owner { + color: var(--text-secondary); + font-size: var(--fs-xs); +} + +.lt-empty { + padding: var(--space-4); + color: var(--text-secondary); + font-size: var(--fs-sm); +} + +/* ------------------------------------------------------------------ + * Unterer Inspector (§17.2, §17.4): vertikal skalierbar, einklappbar + * ------------------------------------------------------------------ */ + +.inspector { + flex: none; + display: flex; + flex-direction: column; + min-height: 0; + background: var(--surface-panel); + border-top: 1px solid var(--border); +} + +.insp-divider { + flex: none; + height: 4px; + cursor: row-resize; + background: transparent; +} + +.insp-divider:hover, +.insp-divider.dragging { + background: var(--accent-line); +} + +.inspector-head { + flex: none; + display: flex; + align-items: center; + gap: var(--space-1); + height: 28px; + padding: 0 var(--space-2); + border-bottom: 1px solid var(--border); +} + +.insp-tab { + padding: 4px 10px; + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: var(--fs-sm); +} + +.insp-tab:hover { + background: var(--surface-hover); + color: var(--text-primary); +} + +.insp-tab[aria-selected='true'] { + background: var(--accent-dim); + color: var(--text-emphasis); +} + +.insp-tools { + margin-left: auto; + display: flex; + align-items: center; + gap: var(--space-1); +} + +.icon-btn { + width: 22px; + height: 22px; + display: grid; + place-items: center; + border-radius: var(--radius-sm); + color: var(--text-secondary); +} + +.icon-btn:hover { + background: var(--surface-hover); + color: var(--text-primary); +} + +.icon-btn.on { + color: var(--accent); +} + +.inspector-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: var(--space-3); +} + +.insp-collapsed { + flex: none; + display: flex; + align-items: center; + gap: var(--space-2); + height: 26px; + padding: 0 var(--space-2); + background: var(--surface-panel); + border-top: 1px solid var(--border); + color: var(--text-secondary); + font-size: var(--fs-sm); +} + +.insp-empty { + color: var(--text-secondary); + font-size: var(--fs-sm); + padding: var(--space-2) 0; +} + +/* Felder im Inspector: dicht, Zahlen präzise editierbar (§17.4) */ +.field-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(196px, 1fr)); + gap: var(--space-2) var(--space-4); + align-content: start; +} + +.field { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.field-label { + font-size: var(--fs-xs); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.field-group { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.group-title { + font-size: var(--fs-xs); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.08em; + border-bottom: 1px solid var(--border); + padding-bottom: 3px; +} + +.numfield { + display: flex; + align-items: center; + gap: 4px; +} + +.numfield input { + width: 76px; + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.drag-grip { + flex: none; + display: grid; + place-items: center; + width: 12px; + height: 22px; + color: var(--text-secondary); + cursor: ew-resize; + user-select: none; +} + +.drag-grip:hover { + color: var(--accent); +} + +.field-value { + display: flex; + align-items: center; + gap: 6px; + height: 22px; + padding: 0 6px; + background: var(--surface-app); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: var(--fs-sm); + color: var(--text-secondary); +} + +.field input[type='text'], +.field select { + width: 100%; +} + +.btn { + height: 22px; + padding: 0 10px; + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-panel-raised); + color: var(--text-primary); + font-size: var(--fs-sm); +} + +.btn:hover { + background: var(--surface-hover); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.toggle-btn { + height: 22px; + padding: 0 10px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-app); + color: var(--text-secondary); + font-size: var(--fs-sm); +} + +.toggle-btn[aria-pressed='true'] { + border-color: var(--accent-line); + background: var(--accent-dim); + color: var(--text-emphasis); +} + +.btn-row { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.note { + font-size: var(--fs-xs); + color: var(--text-secondary); + line-height: 1.5; +} + +/* ------------------------------------------------------------------ + * Rechte Seitenleiste (§17.5) + * ------------------------------------------------------------------ */ + +.sidebar { + flex: none; + width: var(--sidebar-width); + display: flex; + flex-direction: column; + min-height: 0; + background: var(--surface-panel); + border-left: 1px solid var(--border); +} + +.side-head { + flex: none; + display: flex; + align-items: center; + gap: var(--space-1); + height: 28px; + padding: 0 var(--space-2); + border-bottom: 1px solid var(--border); +} + +.seg { + display: flex; + gap: 2px; + padding: 2px; + background: var(--surface-app); + border-radius: var(--radius-md); +} + +.seg button { + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: var(--fs-xs); + color: var(--text-secondary); + white-space: nowrap; +} + +.seg button:hover { + color: var(--text-primary); +} + +.seg button[aria-pressed='true'] { + background: var(--surface-hover); + color: var(--text-emphasis); +} + +.side-body { + flex: 1; + min-height: 0; + overflow: auto; +} + +.side-item { + display: flex; + gap: var(--space-2); + padding: 6px var(--space-2); + border-bottom: 1px solid var(--border); + font-size: var(--fs-sm); + min-width: 0; +} + +.side-item:hover { + background: var(--surface-hover); +} + +.side-item.selected { + background: var(--accent-dim); + box-shadow: inset 2px 0 0 var(--accent); +} + +.thumb { + flex: none; + width: 40px; + height: 24px; + display: grid; + place-items: center; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: rgba(255, 255, 255, 0.8); +} + +.item-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.item-title { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.item-name { + color: var(--text-primary); +} + +.item-meta { + display: flex; + gap: var(--space-2); + color: var(--text-secondary); + font-size: var(--fs-xs); + min-width: 0; +} + +.mini-progress { + position: relative; + flex: 1; + height: 2px; + background: var(--border-strong); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.mini-progress > span { + position: absolute; + top: 0; + left: 0; + bottom: 0; + background: var(--accent); +} + +.role-chip { + flex: none; + padding: 0 4px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + font-size: var(--fs-xs); + color: var(--text-secondary); +} + +.self-tag { + color: var(--accent); +} + +.alert-item { + display: flex; + gap: var(--space-2); + align-items: flex-start; + padding: 6px var(--space-2); + border-bottom: 1px solid var(--border); + font-size: var(--fs-sm); + min-width: 0; +} + +.alert-time { + flex: none; + font-size: var(--fs-xs); + color: var(--text-secondary); +} + +.alert-msg { + min-width: 0; + color: var(--text-primary); + line-height: 1.4; +} + +.empty-note { + padding: var(--space-3); + color: var(--text-secondary); + font-size: var(--fs-sm); +} + +.side-foot { + flex: none; + display: flex; + align-items: center; + justify-content: flex-end; + padding: 4px var(--space-2); + border-top: 1px solid var(--border); +} + +.clear-btn { + font-size: var(--fs-xs); + color: var(--text-secondary); + padding: 2px 6px; + border-radius: var(--radius-sm); +} + +.clear-btn:hover { + background: var(--surface-hover); + color: var(--text-primary); +} + +/* ------------------------------------------------------------------ + * Fußleiste (§17.2): Lock, Zähler, Netzwerk, Panel-Schalter + * ------------------------------------------------------------------ */ + +.footer { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 0 var(--space-3); + background: var(--surface-panel); + border-top: 1px solid var(--border); + font-size: var(--fs-xs); + color: var(--text-secondary); + overflow: hidden; +} + +.foot-item { + display: flex; + align-items: center; + gap: 5px; + white-space: nowrap; +} + +.foot-spacer { + flex: 1; +} + +.foot-warn { + color: var(--state-orange); +} + +.foot-bad { + color: var(--state-red); +} + +/* ------------------------------------------------------------------ + * Responsive Feinschliff (§17.10): schmalere Seitenleiste ab 1280 px + * ------------------------------------------------------------------ */ + +@media (max-width: 1280px) { + :root { + --sidebar-width: 232px; + } +} diff --git a/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css new file mode 100644 index 0000000..6656b61 --- /dev/null +++ b/apps/web/src/styles/tokens.css @@ -0,0 +1,78 @@ +/* + * HMS MediaEngine – Design-Tokens (PLAN.md §17.6). + * + * Zentrale, später thematisierbare Ausgangswerte. + * Zustandsfarben ausschließlich für betriebliche Bedeutung; + * Status wird nie allein über Farbe vermittelt (Icon/Text ergänzen). + */ + +:root { + /* Flächen (§17.6) */ + --surface-app: #0d0f12; /* fast schwarzes Anthrazit (Hintergrund) */ + --surface-panel: #16181d; /* Panel-Grau */ + --surface-panel-raised: #1e2128; /* Panel-Grau hell */ + --surface-hover: #262a33; /* Hover-Fläche */ + + /* Text (§17.6) */ + --text-primary: #c8ccd4; /* helles Neutralgrau */ + --text-secondary: #8a8f9a; /* Sekundärgrau */ + --text-emphasis: #e8eaee; /* wichtige Werte, hoher Kontrast */ + + /* Linien */ + --border: #23262d; + --border-strong: #2e323b; + + /* Akzent: kühles Blau für Selektion/Fokus (§17.6) */ + --accent: #4a90d9; + --accent-dim: rgba(74, 144, 217, 0.16); + --accent-line: rgba(74, 144, 217, 0.45); + + /* Zustandsfarben (§17.6) – keine Deko-Farben */ + --state-green: #3fa34d; + --state-yellow: #d4a017; + --state-orange: #d4641e; + --state-red: #c92a2a; + --state-blue: #4a90d9; + + /* Spacing: 4-px-Raster (§17.6) */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + + /* Radien: 2–4 px, keine Pillen (§17.6) */ + --radius-sm: 2px; + --radius-md: 4px; + + /* Typografie (§17.6) */ + --font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; + --font-mono: 'JetBrains Mono', 'Cascadia Code', 'SF Mono', ui-monospace, Menlo, monospace; + + --fs-xs: 11px; + --fs-sm: 12px; + --fs-md: 13px; + --fs-lg: 14px; + + /* Animationen: 100–180 ms, keine dekorativen Animationen (§17.6) */ + --dur-fast: 100ms; + --dur-med: 180ms; + --ease: ease-out; + + /* Layout-Konstanten des Workspace (§17.2) */ + --bar-height: 32px; /* obere Statusleiste */ + --footer-height: 24px; /* Fußleiste */ + --toolbar-width: 48px; /* linke Werkzeugleiste */ + --toolbar-rail: 16px; /* eingeklappte Werkzeugleiste */ + --sidebar-width: 264px; /* rechte Seitenleiste */ + --row-height: 30px; /* Layer-Zeilen, Desktop-Density 28–32 px */ + --inspector-default: 264px; /* Inspector-Grundhöhe (~30 % bei 900 px) */ + + /* + * Spaltenraster der Layer-Tabelle (§17.3): + * Status | Layer | Typ | Source | Target | Time | Opacity | Blend | FX | Control + */ + --lt-cols: 24px minmax(150px, 1.6fr) 84px minmax(130px, 1.4fr) minmax(108px, 1fr) + 104px 132px 88px 92px 96px; +} diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts new file mode 100644 index 0000000..ed20d63 --- /dev/null +++ b/apps/web/src/types.ts @@ -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>; +} + +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>; +} + +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'; diff --git a/apps/web/src/utils.ts b/apps/web/src/utils.ts new file mode 100644 index 0000000..972fcbd --- /dev/null +++ b/apps/web/src/utils.ts @@ -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 { + 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 = { + 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 = { + 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 = { + 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`; +} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000..bb2895f --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1,16 @@ +/// + +/** + * 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; +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..a8155ff --- /dev/null +++ b/apps/web/tsconfig.json @@ -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"] +} diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo new file mode 100644 index 0000000..533db39 --- /dev/null +++ b/apps/web/tsconfig.tsbuildinfo @@ -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"]} \ No newline at end of file diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..3681b74 --- /dev/null +++ b/apps/web/vite.config.ts @@ -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, + }, +}); diff --git a/docs/adr/0006-frontend-react.md b/docs/adr/0006-frontend-react.md new file mode 100644 index 0000000..a30e182 --- /dev/null +++ b/docs/adr/0006-frontend-react.md @@ -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.