From b70ea7e8773b359d665f73e2427f749d207be49f Mon Sep 17 00:00:00 2001 From: Twopic2 Date: Tue, 17 Feb 2026 17:15:39 -0800 Subject: [PATCH] first commit --- .gitignore | 1 + frontend/index.html | 12 + frontend/package-lock.json | 1726 +++++++++++++++++++++ frontend/package.json | 21 + frontend/src/App.tsx | 280 ++++ frontend/src/components/Controls.tsx | 94 ++ frontend/src/components/Timeline.tsx | 107 ++ frontend/src/components/Upload.tsx | 51 + frontend/src/components/VideoTimeline.tsx | 302 ++++ frontend/src/hooks/useWebSocket.ts | 33 + frontend/src/index.css | 758 +++++++++ frontend/src/main.tsx | 10 + frontend/tsconfig.json | 20 + frontend/vite.config.ts | 15 + go.mod | 12 + go.sum | 10 + handlers/analyze.go | 81 + handlers/download.go | 40 + handlers/export.go | 104 ++ handlers/upload.go | 58 + handlers/ws.go | 47 + main.go | 43 + store/store.go | 48 + transcode/transcode.go | 206 +++ ws/hub.go | 66 + 25 files changed, 4145 insertions(+) create mode 100644 .gitignore create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/Controls.tsx create mode 100644 frontend/src/components/Timeline.tsx create mode 100644 frontend/src/components/Upload.tsx create mode 100644 frontend/src/components/VideoTimeline.tsx create mode 100644 frontend/src/hooks/useWebSocket.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handlers/analyze.go create mode 100644 handlers/download.go create mode 100644 handlers/export.go create mode 100644 handlers/upload.go create mode 100644 handlers/ws.go create mode 100644 main.go create mode 100644 store/store.go create mode 100644 transcode/transcode.go create mode 100644 ws/hub.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..756e32f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/frontend/node_modules/ \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..26f86f4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + A-Roll Cutter + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..4d9c21a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1726 @@ +{ + "name": "aroll-cutter", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aroll-cutter", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.3", + "vite": "^5.4.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..09aa829 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "aroll-cutter", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.3", + "vite": "^5.4.2" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f7223c9 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,280 @@ +import { useState, useCallback } from 'react' +import { useWebSocket } from './hooks/useWebSocket' +import Upload from './components/Upload' +import VideoTimeline from './components/VideoTimeline' +import Controls from './components/Controls' + +const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws` + +type WsMsg = + | { type: 'segments'; segments: { start: number; end: number }[]; duration: number } + | { type: 'progress'; percent: number } + | { type: 'done'; message: string } + | { type: 'error'; message: string } + +type Phase = 'idle' | 'uploading' | 'analyzing' | 'ready' | 'exporting' | 'done' + +interface Segment { + start: number + end: number + kept: boolean +} + +interface State { + phase: Phase + filename: string | null + videoUrl: string | null // blob URL for in-browser preview + thumbnail extraction + duration: number + segments: Segment[] + progress: number + outputFile: string | null + error: string | null + noiseDb: number + minSilence: number + padding: number +} + +const INITIAL: State = { + phase: 'idle', + filename: null, + videoUrl: null, + duration: 0, + segments: [], + progress: 0, + outputFile: null, + error: null, + noiseDb: -30, + minSilence: 0.5, + padding: 0.1, +} + +export default function App() { + const [state, setState] = useState(INITIAL) + + const handleMessage = useCallback((raw: unknown) => { + const msg = raw as WsMsg + setState((prev) => { + switch (msg.type) { + case 'segments': + return { + ...prev, + phase: 'ready', + duration: msg.duration, + segments: msg.segments.map((s) => ({ ...s, kept: true })), + } + case 'progress': + return { ...prev, progress: msg.percent } + case 'done': + return { ...prev, phase: 'done', outputFile: msg.message, progress: 100 } + case 'error': + return { ...prev, phase: 'ready', error: msg.message } + default: + return prev + } + }) + }, []) + + useWebSocket(WS_URL, handleMessage) + + // ------------------------------------------------------------------ + // Actions + // ------------------------------------------------------------------ + + const runAnalyze = async ( + filename: string, + noiseDb: number, + minSilence: number, + padding: number, + ) => { + setState((prev) => ({ ...prev, phase: 'analyzing', segments: [], error: null })) + try { + const res = await fetch('/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename, noiseDb, minSilence, padding }), + }) + if (!res.ok) throw new Error(await res.text()) + // Segments stream back via WebSocket → handleMessage + } catch (e) { + setState((prev) => ({ ...prev, phase: 'ready', error: String(e) })) + } + } + + const handleUpload = async (file: File) => { + const videoUrl = URL.createObjectURL(file) + setState((prev) => ({ ...prev, phase: 'uploading', error: null, videoUrl })) + try { + const form = new FormData() + form.append('video', file) + const res = await fetch('/upload', { method: 'POST', body: form }) + if (!res.ok) throw new Error(await res.text()) + const { filename } = (await res.json()) as { filename: string } + setState((prev) => ({ ...prev, filename })) + await runAnalyze(filename, state.noiseDb, state.minSilence, state.padding) + } catch (e) { + setState((prev) => ({ ...prev, phase: 'idle', error: String(e) })) + } + } + + const handleReanalyze = () => { + if (!state.filename) return + runAnalyze(state.filename, state.noiseDb, state.minSilence, state.padding) + } + + const handleToggle = (index: number) => { + setState((prev) => ({ + ...prev, + segments: prev.segments.map((s, i) => (i === index ? { ...s, kept: !s.kept } : s)), + })) + } + + const handleExport = async () => { + if (!state.filename) return + const kept = state.segments.filter((s) => s.kept).map(({ start, end }) => ({ start, end })) + setState((prev) => ({ ...prev, phase: 'exporting', progress: 0, outputFile: null, error: null })) + try { + const res = await fetch('/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: state.filename, segments: kept }), + }) + if (!res.ok) throw new Error(await res.text()) + } catch (e) { + setState((prev) => ({ ...prev, phase: 'ready', error: String(e) })) + } + } + + const handleReset = () => { + if (state.videoUrl) URL.revokeObjectURL(state.videoUrl) + setState(INITIAL) + } + + // ------------------------------------------------------------------ + // Derived + // ------------------------------------------------------------------ + + const keptDuration = state.segments + .filter((s) => s.kept) + .reduce((acc, s) => acc + (s.end - s.start), 0) + const removedDuration = state.duration - keptDuration + const keptCount = state.segments.filter((s) => s.kept).length + + // Show the video timeline as soon as we have the blob URL + const showTimeline = state.videoUrl !== null && state.phase !== 'idle' + + return ( +
+
+

A-Roll Cutter

+

Automatically removes silence and warm-up from your recordings

+
+ +
+ {state.error && ( +
+ {state.error} + +
+ )} + + {/* ── Upload ── */} + {state.phase === 'idle' && } + + {/* ── Uploading spinner ── */} + {state.phase === 'uploading' && ( +
+
+

Uploading video...

+
+ )} + + {/* ── Video preview + timeline (shown as soon as we have the blob URL) ── */} + {showTimeline && ( + + )} + + {/* ── Analyzing overlay (shown below the timeline while we wait) ── */} + {state.phase === 'analyzing' && ( +
+
+

Detecting silence...

+

FFmpeg is scanning the audio track

+
+ )} + + {/* ── Controls (only once we have segments) ── */} + {(state.phase === 'ready' || state.phase === 'exporting' || state.phase === 'done') && ( + <> + setState((p) => ({ ...p, noiseDb: v }))} + onMinSilence={(v) => setState((p) => ({ ...p, minSilence: v }))} + onPadding={(v) => setState((p) => ({ ...p, padding: v }))} + onReanalyze={handleReanalyze} + /> + +
+ Keeping {fmtTime(keptDuration)} + Removing {fmtTime(removedDuration)} + + {keptCount} segments · {fmtTime(state.duration)} total + +
+ + {state.phase === 'ready' && ( +
+ + +
+ )} + + {state.phase === 'exporting' && ( +
+

Exporting... {Math.round(state.progress)}%

+
+
+
+
+ )} + + {state.phase === 'done' && state.outputFile && ( +
+ +

Export complete!

+ + Download + + +
+ )} + + )} +
+
+ ) +} + +function fmtTime(s: number): string { + const m = Math.floor(s / 60) + const sec = Math.floor(s % 60) + return m > 0 ? `${m}m ${sec}s` : `${sec}s` +} diff --git a/frontend/src/components/Controls.tsx b/frontend/src/components/Controls.tsx new file mode 100644 index 0000000..94451ef --- /dev/null +++ b/frontend/src/components/Controls.tsx @@ -0,0 +1,94 @@ +interface Props { + noiseDb: number + minSilence: number + padding: number + disabled: boolean + onNoiseDb: (v: number) => void + onMinSilence: (v: number) => void + onPadding: (v: number) => void + onReanalyze: () => void +} + +export default function Controls({ + noiseDb, + minSilence, + padding, + disabled, + onNoiseDb, + onMinSilence, + onPadding, + onReanalyze, +}: Props) { + return ( +
+
+ + onNoiseDb(Number(e.target.value))} + /> +
+ Quiet (−60) + Loud (−10) +
+

+ Lower = only very quiet gaps removed. Raise if pauses are not being caught. +

+
+ +
+ + onMinSilence(Number(e.target.value))} + /> +
+ 0.1s + 3.0s +
+

Gaps shorter than this are left in.

+
+ +
+ + onPadding(Number(e.target.value))} + /> +
+ None + 0.5s +
+

Buffer kept around each speech burst to avoid clipping words.

+
+ + +
+ ) +} diff --git a/frontend/src/components/Timeline.tsx b/frontend/src/components/Timeline.tsx new file mode 100644 index 0000000..517da3d --- /dev/null +++ b/frontend/src/components/Timeline.tsx @@ -0,0 +1,107 @@ +export interface Segment { + start: number + end: number + kept: boolean +} + +interface TimelineBlock { + start: number + end: number + type: 'speech' | 'silence' + segmentIndex?: number + kept?: boolean +} + +interface Props { + segments: Segment[] + duration: number + onToggle: (index: number) => void + disabled: boolean +} + +export default function Timeline({ segments, duration, onToggle, disabled }: Props) { + if (duration === 0 || segments.length === 0) return null + + const blocks = buildBlocks(segments, duration) + const keptCount = segments.filter((s) => s.kept).length + + return ( +
+
+ Timeline + + {keptCount} of {segments.length} segments kept — click a segment to toggle it + +
+ +
+ {blocks.map((block, i) => { + const widthPct = ((block.end - block.start) / duration) * 100 + const isSpeech = block.type === 'speech' + const isKept = isSpeech && block.kept + + let className = 'timeline-block ' + if (!isSpeech) className += 'silence' + else if (isKept) className += 'kept' + else className += 'skipped' + + const label = `${block.type}: ${fmt(block.start)} → ${fmt(block.end)} (${fmt(block.end - block.start)})` + + return ( +
{ + if (!disabled && isSpeech && block.segmentIndex !== undefined) { + onToggle(block.segmentIndex) + } + }} + /> + ) + })} +
+ +
+ 0:00 + {fmt(duration / 4)} + {fmt(duration / 2)} + {fmt((duration * 3) / 4)} + {fmt(duration)} +
+ +
+ ■ Speech (kept) + ■ Speech (toggled off) + ▨ Silence (will be cut) +
+
+ ) +} + +function buildBlocks(segments: Segment[], duration: number): TimelineBlock[] { + const blocks: TimelineBlock[] = [] + let cursor = 0 + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] + if (seg.start > cursor + 0.01) { + blocks.push({ start: cursor, end: seg.start, type: 'silence' }) + } + blocks.push({ start: seg.start, end: seg.end, type: 'speech', segmentIndex: i, kept: seg.kept }) + cursor = seg.end + } + + if (cursor < duration - 0.01) { + blocks.push({ start: cursor, end: duration, type: 'silence' }) + } + + return blocks +} + +function fmt(s: number): string { + const m = Math.floor(s / 60) + const sec = (s % 60).toFixed(1).padStart(4, '0') + return m > 0 ? `${m}:${sec}` : `${sec}s` +} diff --git a/frontend/src/components/Upload.tsx b/frontend/src/components/Upload.tsx new file mode 100644 index 0000000..18711d6 --- /dev/null +++ b/frontend/src/components/Upload.tsx @@ -0,0 +1,51 @@ +import { useState, useRef, DragEvent, ChangeEvent } from 'react' + +interface Props { + onUpload: (file: File) => void +} + +const ACCEPTED_EXTENSIONS = /\.(mp4|mov|avi|webm|mkv)$/i + +export default function Upload({ onUpload }: Props) { + const [dragging, setDragging] = useState(false) + const inputRef = useRef(null) + + const submit = (file: File) => { + if (!ACCEPTED_EXTENSIONS.test(file.name)) return + onUpload(file) + } + + const onDrop = (e: DragEvent) => { + e.preventDefault() + setDragging(false) + const file = e.dataTransfer.files[0] + if (file) submit(file) + } + + const onChange = (e: ChangeEvent) => { + const file = e.target.files?.[0] + if (file) submit(file) + } + + return ( +
inputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + > +
+

Drop your A-roll clip here

+

or click to browse

+

MP4 · MOV · AVI · WebM · MKV

+ +
+ ) +} diff --git a/frontend/src/components/VideoTimeline.tsx b/frontend/src/components/VideoTimeline.tsx new file mode 100644 index 0000000..91d5fa8 --- /dev/null +++ b/frontend/src/components/VideoTimeline.tsx @@ -0,0 +1,302 @@ +import { useRef, useState, useEffect, useCallback } from 'react' +import type { Segment } from './Timeline' + +const PX_PER_SEC = 80 // timeline scale: 80px = 1 second +const THUMB_H = 60 // rendered thumbnail strip height +const CAP_W = 120 // canvas capture width +const CAP_H = 68 // canvas capture height (16:9 ≈ 120×68) + +interface Props { + videoUrl: string + duration: number + segments: Segment[] + onToggle: (index: number) => void + disabled: boolean +} + +export default function VideoTimeline({ videoUrl, duration, segments, onToggle, disabled }: Props) { + const videoRef = useRef(null) + const scrollRef = useRef(null) + const trackRef = useRef(null) + + const [thumbnails, setThumbnails] = useState([]) + const [currentTime, setCurrentTime] = useState(0) + const [playing, setPlaying] = useState(false) + const [scrubbing, setScrubbing] = useState(false) + + // Get intrinsic duration from the video element itself so we can show thumbnails + // even before the server responds with the segments message. + const [intrinsicDuration, setIntrinsicDuration] = useState(duration) + const effectiveDuration = duration > 0 ? duration : intrinsicDuration + + useEffect(() => { + const v = videoRef.current + if (!v) return + const onMeta = () => { if (isFinite(v.duration)) setIntrinsicDuration(v.duration) } + v.addEventListener('loadedmetadata', onMeta) + if (isFinite(v.duration) && v.duration > 0) setIntrinsicDuration(v.duration) + return () => v.removeEventListener('loadedmetadata', onMeta) + }, []) + + const trackWidth = Math.max(Math.round(effectiveDuration * PX_PER_SEC), 1) + const thumbCount = Math.max(Math.ceil(trackWidth / 80), 1) + const thumbW = trackWidth / thumbCount + + // Extract frames client-side — no server round-trip needed for thumbnails + useEffect(() => { + if (!videoUrl || effectiveDuration === 0) return + setThumbnails([]) + + const vid = document.createElement('video') + const canvas = document.createElement('canvas') + canvas.width = CAP_W + canvas.height = CAP_H + const ctx = canvas.getContext('2d')! + + const result: string[] = [] + let idx = 0 + let cancelled = false + + const captureNext = () => { + if (cancelled || idx >= thumbCount) return + vid.currentTime = ((idx + 0.5) / thumbCount) * effectiveDuration + } + + vid.addEventListener('loadedmetadata', () => { if (!cancelled) captureNext() }) + vid.addEventListener('seeked', () => { + if (cancelled) return + ctx.drawImage(vid, 0, 0, CAP_W, CAP_H) + result.push(canvas.toDataURL('image/jpeg', 0.55)) + idx++ + setThumbnails([...result]) + captureNext() + }) + + vid.src = videoUrl + vid.muted = true + vid.preload = 'auto' + + return () => { + cancelled = true + vid.src = '' + } + }, [videoUrl, effectiveDuration, thumbCount]) + + // Keep currentTime in sync with the video element + useEffect(() => { + const v = videoRef.current + if (!v) return + const onTime = () => { if (!scrubbing) setCurrentTime(v.currentTime) } + const onPlay = () => setPlaying(true) + const onPause = () => setPlaying(false) + const onEnded = () => setPlaying(false) + v.addEventListener('timeupdate', onTime) + v.addEventListener('play', onPlay) + v.addEventListener('pause', onPause) + v.addEventListener('ended', onEnded) + return () => { + v.removeEventListener('timeupdate', onTime) + v.removeEventListener('play', onPlay) + v.removeEventListener('pause', onPause) + v.removeEventListener('ended', onEnded) + } + }, [scrubbing]) + + // Auto-scroll the timeline to follow the playhead while playing + useEffect(() => { + if (!playing || !scrollRef.current || effectiveDuration === 0) return + const el = scrollRef.current + const x = (currentTime / effectiveDuration) * trackWidth + const { scrollLeft, clientWidth } = el + if (x > scrollLeft + clientWidth - 80 || x < scrollLeft + 40) { + el.scrollLeft = x - clientWidth / 2 + } + }, [currentTime, playing, effectiveDuration, trackWidth]) + + // Map a clientX position to a video timestamp + const getTimeAt = useCallback((clientX: number): number => { + if (!trackRef.current || effectiveDuration === 0) return 0 + const rect = trackRef.current.getBoundingClientRect() + const x = clientX - rect.left + return Math.max(0, Math.min(effectiveDuration, (x / trackWidth) * effectiveDuration)) + }, [effectiveDuration, trackWidth]) + + const seekTo = useCallback((time: number) => { + setCurrentTime(time) + if (videoRef.current) videoRef.current.currentTime = time + }, []) + + // Click / drag on the track to scrub + const onTrackMouseDown = (e: React.MouseEvent) => { + if (e.button !== 0) return + setScrubbing(true) + seekTo(getTimeAt(e.clientX)) + videoRef.current?.pause() + } + + useEffect(() => { + if (!scrubbing) return + const onMove = (e: MouseEvent) => seekTo(getTimeAt(e.clientX)) + const onUp = () => setScrubbing(false) + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + return () => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + }, [scrubbing, getTimeAt, seekTo]) + + const togglePlay = () => { + const v = videoRef.current + if (!v) return + playing ? v.pause() : v.play() + } + + const silences = buildSilences(segments, effectiveDuration) + const ticks = buildTicks(effectiveDuration, trackWidth) + const playheadX = effectiveDuration > 0 ? (currentTime / effectiveDuration) * trackWidth : 0 + const keptCount = segments.filter((s) => s.kept).length + + return ( +
+ {/* ── Video preview ── */} +
+
+ + {/* ── Scrollable timeline ── */} +
+ {/* Ruler sits inside scroll so it tracks with the track */} +
+ {ticks.map((t, i) => ( + + {t.label} + + ))} +
+ +
+ {/* ① Thumbnail strip */} +
+ {Array.from({ length: thumbCount }, (_, i) => ( +
+ {thumbnails[i] ? ( + + ) : ( +
+ )} +
+ ))} +
+ + {/* ② Silence highlights — overlaid on thumbnails */} + {silences.map((r, i) => ( +
+ ))} + + {/* ③ Speech segment handles */} + {segments.map((seg, i) => ( +
{ if (!disabled) onToggle(i) }} + title={ + seg.kept + ? `Speech ${fmtTime(seg.start)}–${fmtTime(seg.end)} · click to exclude` + : `Excluded ${fmtTime(seg.start)}–${fmtTime(seg.end)} · click to include` + } + /> + ))} + + {/* ④ Playhead */} +
+
+
+
+
+
+ + {/* ── Legend ── */} +
+ ■ Speech kept ({keptCount}) + ■ Toggled off + ▨ Silence — will be removed + {!disabled && ( + Click a speech region to include / exclude it + )} +
+
+ ) +} + +// Invert the speech segments to get silence gaps +function buildSilences(segments: Segment[], duration: number) { + if (duration === 0) return [] + const out: { start: number; end: number }[] = [] + let cursor = 0 + for (const seg of segments) { + if (seg.start > cursor + 0.01) out.push({ start: cursor, end: seg.start }) + cursor = seg.end + } + if (cursor < duration - 0.01) out.push({ start: cursor, end: duration }) + return out +} + +// Build ruler tick marks, aiming for one tick every ~120px +function buildTicks(duration: number, trackWidth: number) { + if (duration === 0 || trackWidth === 0) return [] + const ticks: { x: number; label: string }[] = [] + const rawStep = (120 / trackWidth) * duration + const steps = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600] + const step = steps.find((s) => s >= rawStep) ?? 600 + for (let t = 0; t <= duration + 0.001; t += step) { + ticks.push({ x: (t / duration) * trackWidth, label: fmtTime(t) }) + } + return ticks +} + +function fmtTime(s: number): string { + const m = Math.floor(s / 60) + const sec = Math.floor(s % 60).toString().padStart(2, '0') + return `${m}:${sec}` +} diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..e5cd5b4 --- /dev/null +++ b/frontend/src/hooks/useWebSocket.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef, useCallback } from 'react' + +type Handler = (data: unknown) => void + +export function useWebSocket(url: string, onMessage: Handler) { + const wsRef = useRef(null) + // Keep a stable ref to the handler so we don't reconnect on every render + const handlerRef = useRef(onMessage) + handlerRef.current = onMessage + + useEffect(() => { + const socket = new WebSocket(url) + wsRef.current = socket + + socket.onmessage = (e) => { + try { + handlerRef.current(JSON.parse(e.data as string)) + } catch { + // ignore malformed frames + } + } + + socket.onerror = (e) => console.error('WebSocket error', e) + + return () => socket.close() + }, [url]) + + const send = useCallback((data: unknown) => { + wsRef.current?.send(JSON.stringify(data)) + }, []) + + return { send } +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..e2ce75a --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,758 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #111111; + --surface: #1c1c1c; + --surface-2: #252525; + --border: #2e2e2e; + --border-2: #3a3a3a; + --text: #e2e2e2; + --text-muted: #777; + --text-dim: #555; + --green: #22c55e; + --green-dim: #14532d; + --red: #ef4444; + --blue: #3b82f6; + --blue-hover: #2563eb; +} + +html, body { + height: 100%; +} + +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + font-size: 14px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +/* ------------------------------------------------------------------ */ +/* App shell */ +/* ------------------------------------------------------------------ */ + +.app { + max-width: 860px; + margin: 0 auto; + padding: 32px 20px 64px; +} + +.app-header { + margin-bottom: 36px; +} + +.app-header h1 { + font-size: 22px; + font-weight: 600; + letter-spacing: -0.4px; + color: #fff; +} + +.app-header p { + margin-top: 4px; + color: var(--text-muted); + font-size: 13px; +} + +.app-main { + display: flex; + flex-direction: column; + gap: 14px; +} + +/* ------------------------------------------------------------------ */ +/* Upload zone */ +/* ------------------------------------------------------------------ */ + +.upload-zone { + border: 2px dashed var(--border-2); + border-radius: 12px; + padding: 72px 40px; + text-align: center; + cursor: pointer; + transition: border-color 0.18s, background 0.18s; + user-select: none; +} + +.upload-zone:hover, +.upload-zone.dragging { + border-color: var(--blue); + background: rgba(59, 130, 246, 0.04); +} + +.upload-icon { + font-size: 36px; + opacity: 0.35; + margin-bottom: 14px; + line-height: 1; +} + +.upload-label { + font-size: 17px; + font-weight: 500; + color: #fff; + margin-bottom: 6px; +} + +.upload-sub { + color: var(--text-muted); + margin-bottom: 16px; +} + +.upload-formats { + display: inline-block; + padding: 4px 14px; + border-radius: 20px; + background: var(--surface); + border: 1px solid var(--border); + font-size: 12px; + color: var(--text-muted); + letter-spacing: 0.5px; +} + +/* ------------------------------------------------------------------ */ +/* Status card (uploading / analyzing) */ +/* ------------------------------------------------------------------ */ + +.status-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + padding: 64px 40px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + color: var(--text-muted); +} + +.status-sub { + font-size: 12px; + color: var(--text-dim); +} + +.spinner { + width: 28px; + height: 28px; + border: 2px solid var(--border-2); + border-top-color: var(--blue); + border-radius: 50%; + animation: spin 0.75s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ------------------------------------------------------------------ */ +/* Controls */ +/* ------------------------------------------------------------------ */ + +.controls { + display: grid; + grid-template-columns: 1fr 1fr 1fr auto; + gap: 20px; + align-items: end; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.control-group label { + display: flex; + justify-content: space-between; + font-size: 12px; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.control-value { + color: var(--text); + font-variant-numeric: tabular-nums; + text-transform: none; + letter-spacing: 0; + font-weight: 400; +} + +.control-group input[type='range'] { + width: 100%; + height: 4px; + accent-color: var(--blue); + cursor: pointer; +} + +.control-group input[type='range']:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.control-hint { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--text-dim); +} + +.control-tip { + font-size: 11px; + color: var(--text-dim); + line-height: 1.4; +} + +.reanalyze-btn { + white-space: nowrap; + align-self: end; +} + +/* ------------------------------------------------------------------ */ +/* Timeline */ +/* ------------------------------------------------------------------ */ + +.timeline-container { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; +} + +.timeline-header { + display: flex; + align-items: baseline; + gap: 12px; + margin-bottom: 14px; +} + +.timeline-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text-muted); +} + +.timeline-subtitle { + font-size: 12px; + color: var(--text-dim); +} + +.timeline-bar { + display: flex; + height: 56px; + border-radius: 6px; + overflow: hidden; + background: var(--border); + gap: 1px; +} + +.timeline-block { + height: 100%; + min-width: 1px; + transition: filter 0.12s; + flex-shrink: 0; +} + +.timeline-block.kept { + background: var(--green); + cursor: pointer; +} + +.timeline-block.kept:hover { + filter: brightness(1.2); +} + +.timeline-block.skipped { + background: var(--green-dim); + cursor: pointer; + opacity: 0.6; +} + +.timeline-block.skipped:hover { + opacity: 0.8; +} + +.timeline-block.silence { + background: rgba(239, 68, 68, 0.75); + cursor: default; + position: relative; +} + +/* Diagonal stripe overlay to reinforce "cut" visually */ +.timeline-block.silence::after { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + -45deg, + transparent, + transparent 4px, + rgba(0, 0, 0, 0.25) 4px, + rgba(0, 0, 0, 0.25) 8px + ); +} + +.timeline-labels { + display: flex; + justify-content: space-between; + margin-top: 7px; + font-size: 11px; + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} + +.timeline-legend { + display: flex; + gap: 20px; + margin-top: 12px; + font-size: 12px; +} + +.legend-kept { color: var(--green); } +.legend-skipped { color: #2d6a42; } +.legend-silence { color: var(--red); } + +/* ------------------------------------------------------------------ */ +/* Stats row */ +/* ------------------------------------------------------------------ */ + +.stats { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.stat { + padding: 6px 14px; + border-radius: 6px; + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.stat-kept { background: rgba(34, 197, 94, 0.08); color: var(--green); } +.stat-removed { background: rgba(239, 68, 68, 0.08); color: var(--red); } +.stat-total { background: var(--surface); color: var(--text-muted); border: 1px solid var(--border); } + +/* ------------------------------------------------------------------ */ +/* Action row */ +/* ------------------------------------------------------------------ */ + +.actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +/* ------------------------------------------------------------------ */ +/* Buttons */ +/* ------------------------------------------------------------------ */ + +.btn-primary, +.btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 9px 22px; + border-radius: 8px; + border: none; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; + text-decoration: none; +} + +.btn-primary { + background: var(--blue); + color: #fff; +} + +.btn-primary:hover { + background: var(--blue-hover); +} + +.btn-primary:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btn-secondary { + background: var(--surface-2); + color: var(--text); + border: 1px solid var(--border-2); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--border); +} + +.btn-secondary:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ------------------------------------------------------------------ */ +/* Export progress */ +/* ------------------------------------------------------------------ */ + +.progress-section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.progress-label { + font-size: 13px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.progress-track { + height: 6px; + background: var(--border-2); + border-radius: 3px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--blue); + border-radius: 3px; + transition: width 0.3s ease; +} + +/* ------------------------------------------------------------------ */ +/* Done panel */ +/* ------------------------------------------------------------------ */ + +.done-panel { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + background: var(--surface); + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: 12px; + padding: 20px 24px; +} + +.done-check { + font-size: 20px; + color: var(--green); +} + +.done-panel p { + flex: 1; + font-weight: 500; + color: var(--green); +} + +/* ------------------------------------------------------------------ */ +/* Error banner */ +/* ------------------------------------------------------------------ */ + +.error-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-radius: 8px; + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.3); + color: var(--red); + font-size: 13px; +} + +.error-banner button { + background: none; + border: none; + color: var(--red); + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 0 4px; + opacity: 0.7; +} + +.error-banner button:hover { + opacity: 1; +} + +/* ------------------------------------------------------------------ */ +/* status-card variant — compact, used while analyzing below timeline */ +/* ------------------------------------------------------------------ */ + +.status-card--inline { + flex-direction: row; + padding: 16px 20px; + gap: 12px; + justify-content: flex-start; +} + +/* ------------------------------------------------------------------ */ +/* VideoTimeline */ +/* ------------------------------------------------------------------ */ + +.vt-root { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* ── Video preview ── */ + +.vt-preview { + position: relative; + background: #000; + display: flex; + justify-content: center; + align-items: center; + min-height: 120px; + max-height: 320px; + overflow: hidden; + border-bottom: 1px solid var(--border); +} + +.vt-video { + max-width: 100%; + max-height: 320px; + display: block; + cursor: pointer; +} + +.vt-controls-bar { + position: absolute; + bottom: 10px; + left: 10px; + display: flex; + align-items: center; + gap: 10px; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(6px); + border-radius: 20px; + padding: 5px 14px; +} + +.vt-play-btn { + background: none; + border: none; + color: #fff; + font-size: 15px; + cursor: pointer; + line-height: 1; + padding: 0; + opacity: 0.9; +} + +.vt-play-btn:hover { opacity: 1; } + +.vt-timecode { + font-size: 12px; + color: rgba(255, 255, 255, 0.85); + font-variant-numeric: tabular-nums; + font-family: ui-monospace, monospace; +} + +/* ── Scrollable area ── */ + +.vt-scroll { + overflow-x: auto; + overflow-y: hidden; + background: #141414; + scrollbar-width: thin; + scrollbar-color: var(--border-2) transparent; + padding-bottom: 4px; +} + +.vt-scroll::-webkit-scrollbar { height: 5px; } +.vt-scroll::-webkit-scrollbar-track { background: transparent; } +.vt-scroll::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 3px; } + +/* ── Ruler ── */ + +.vt-ruler { + position: relative; + height: 22px; + border-bottom: 1px solid var(--border); +} + +.vt-tick { + position: absolute; + top: 5px; + font-size: 10px; + color: var(--text-dim); + transform: translateX(-50%); + white-space: nowrap; + font-variant-numeric: tabular-nums; + pointer-events: none; +} + +.vt-tick::before { + content: ''; + position: absolute; + top: -5px; + left: 50%; + width: 1px; + height: 4px; + background: var(--border-2); +} + +/* ── Track (the full-width scrollable content) ── */ + +.vt-track { + position: relative; + cursor: crosshair; + user-select: none; +} + +/* ── Thumbnails ── */ + +.vt-thumbs { + display: flex; +} + +.vt-thumb { + flex-shrink: 0; + overflow: hidden; + border-right: 1px solid rgba(0, 0, 0, 0.4); +} + +.vt-thumb img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.vt-thumb-skeleton { + width: 100%; + height: 100%; + background: var(--surface-2); + animation: pulse 1.4s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.35; } + 50% { opacity: 0.65; } +} + +/* ── Silence highlights ── */ + +.vt-silence { + position: absolute; + top: 0; + pointer-events: none; + z-index: 2; + background: rgba(239, 68, 68, 0.52); +} + +/* Diagonal-stripe overlay for the silence regions */ +.vt-silence::after { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + -45deg, + transparent, + transparent 5px, + rgba(0, 0, 0, 0.28) 5px, + rgba(0, 0, 0, 0.28) 10px + ); +} + +/* ── Speech segment handles ── */ + +.vt-seg { + position: absolute; + top: 0; + z-index: 3; + pointer-events: auto; + cursor: pointer; + box-sizing: border-box; + transition: background 0.12s; +} + +.vt-seg.kept { + border-top: 3px solid var(--green); + border-bottom: 3px solid var(--green); + background: rgba(34, 197, 94, 0.07); +} + +.vt-seg.kept:hover { + background: rgba(34, 197, 94, 0.22); +} + +.vt-seg.skipped { + border-top: 3px solid #444; + border-bottom: 3px solid #444; + background: rgba(0, 0, 0, 0.45); +} + +.vt-seg.skipped:hover { + background: rgba(0, 0, 0, 0.25); +} + +/* ── Playhead ── */ + +.vt-playhead { + position: absolute; + top: -8px; + transform: translateX(-50%); + z-index: 10; + pointer-events: none; + display: flex; + flex-direction: column; + align-items: center; +} + +/* Small triangle at top */ +.vt-playhead-nub { + width: 10px; + height: 7px; + background: #fff; + clip-path: polygon(0 0, 100% 0, 50% 100%); +} + +.vt-playhead-line { + width: 1.5px; + background: rgba(255, 255, 255, 0.9); +} + +/* ── Legend ── */ + +.vt-legend { + display: flex; + flex-wrap: wrap; + gap: 16px; + padding: 10px 16px; + border-top: 1px solid var(--border); + font-size: 12px; + background: var(--surface); +} + +.vt-leg { color: var(--text-muted); } +.vt-leg-kept { color: var(--green); } +.vt-leg-skip { color: #2d6a42; } +.vt-leg-sil { color: var(--red); } +.vt-leg-hint { margin-left: auto; color: var(--text-dim); font-style: italic; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..520b520 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..a4c834a --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..7d248fa --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/upload': 'http://localhost:8080', + '/analyze': 'http://localhost:8080', + '/export': 'http://localhost:8080', + '/download': 'http://localhost:8080', + '/ws': { target: 'ws://localhost:8080', ws: true }, + }, + }, +}) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7ea9b41 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module aroll + +go 1.25.7 + +require github.com/gorilla/websocket v1.5.3 + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect + go.uber.org/atomic v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..049c9b9 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= diff --git a/handlers/analyze.go b/handlers/analyze.go new file mode 100644 index 0000000..59f4e22 --- /dev/null +++ b/handlers/analyze.go @@ -0,0 +1,81 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + + "aroll/store" + "aroll/transcode" + "aroll/ws" +) + +type analyzeRequest struct { + Filename string `json:"filename"` + NoiseDb float64 `json:"noiseDb"` + MinSilence float64 `json:"minSilence"` + Padding float64 `json:"padding"` +} + +// AnalyzeHandler fetches the video bytes from Redis, writes them to a short-lived +// temp file for FFmpeg, runs silence detection, then deletes the temp file. +func AnalyzeHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req analyzeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad JSON: "+err.Error(), http.StatusBadRequest) + return + } + + data, err := st.Get(context.Background(), req.Filename) + if err != nil { + http.Error(w, "file not found in store (may have expired)", http.StatusNotFound) + return + } + + ext := filepath.Ext(req.Filename) + + go func() { + // Write to a temp file — FFmpeg needs a file path, not a byte slice. + // This file exists only for the duration of the FFmpeg scan. + tmp, err := os.CreateTemp("", "aroll-analyze-*"+ext) + if err != nil { + broadcastError(hub, "create temp: "+err.Error()) + return + } + defer os.Remove(tmp.Name()) + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + broadcastError(hub, "write temp: "+err.Error()) + return + } + tmp.Close() + + _, err = transcode.DetectSpeechSegments( + tmp.Name(), + req.NoiseDb, + req.MinSilence, + req.Padding, + hub.Broadcast, + ) + if err != nil { + broadcastError(hub, err.Error()) + } + }() + + w.WriteHeader(http.StatusAccepted) + } +} + +func broadcastError(hub *ws.Hub, msg string) { + data, _ := json.Marshal(map[string]string{"type": "error", "message": msg}) + hub.Broadcast(data) +} diff --git a/handlers/download.go b/handlers/download.go new file mode 100644 index 0000000..cf34edf --- /dev/null +++ b/handlers/download.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + "strings" + + "aroll/store" +) + +// DownloadHandler fetches the exported video from Redis, streams it to the +// browser, then immediately deletes the key. One download per export. +func DownloadHandler(st *store.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + key := strings.TrimPrefix(r.URL.Path, "/") + + // Only serve keys we created in ExportHandler + if !strings.HasPrefix(key, "output-") { + http.NotFound(w, r) + return + } + + ctx := context.Background() + + data, err := st.Get(ctx, key) + if err != nil { + http.Error(w, "file not found or already downloaded", http.StatusNotFound) + return + } + + // Delete from Redis now — one download only + defer st.Delete(ctx, key) + + w.Header().Set("Content-Type", "video/mp4") + w.Header().Set("Content-Disposition", `attachment; filename="aroll-cut.mp4"`) + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + w.Write(data) + } +} diff --git a/handlers/export.go b/handlers/export.go new file mode 100644 index 0000000..59886b9 --- /dev/null +++ b/handlers/export.go @@ -0,0 +1,104 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + + "aroll/store" + "aroll/transcode" + "aroll/ws" +) + +type exportRequest struct { + Filename string `json:"filename"` + Segments []transcode.Segment `json:"segments"` +} + +// ExportHandler fetches the input video from Redis, runs FFmpeg to cut the +// silence, stores the output back in Redis, then cleans up the temp files. +// The output key is sent to the frontend via a "done" WebSocket message. +func ExportHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req exportRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad JSON: "+err.Error(), http.StatusBadRequest) + return + } + if len(req.Segments) == 0 { + http.Error(w, "no segments", http.StatusBadRequest) + return + } + + data, err := st.Get(context.Background(), req.Filename) + if err != nil { + http.Error(w, "file not found in store (may have expired)", http.StatusNotFound) + return + } + + ext := filepath.Ext(req.Filename) + + go func() { + // Write input bytes to a temp file for FFmpeg + inTmp, err := os.CreateTemp("", "aroll-in-*"+ext) + if err != nil { + broadcastError(hub, "create input temp: "+err.Error()) + return + } + defer os.Remove(inTmp.Name()) + + if _, err := inTmp.Write(data); err != nil { + inTmp.Close() + broadcastError(hub, "write input temp: "+err.Error()) + return + } + inTmp.Close() + + // Create an output temp file for FFmpeg to write into + outTmp, err := os.CreateTemp("", "aroll-out-*.mp4") + if err != nil { + broadcastError(hub, "create output temp: "+err.Error()) + return + } + outTmp.Close() + defer os.Remove(outTmp.Name()) + + // Run FFmpeg — progress streamed via WebSocket + if err := transcode.ExportSegments( + inTmp.Name(), outTmp.Name(), req.Segments, hub.Broadcast, + ); err != nil { + broadcastError(hub, err.Error()) + return + } + + // Read the output bytes and store them in Redis + outData, err := os.ReadFile(outTmp.Name()) + if err != nil { + broadcastError(hub, "read output: "+err.Error()) + return + } + + outputKey := "output-" + store.NewID() + if err := st.Set(context.Background(), outputKey, outData); err != nil { + broadcastError(hub, "store output: "+err.Error()) + return + } + + // Tell the frontend where to download from + msg, _ := json.Marshal(map[string]string{ + "type": "done", + "message": outputKey, + }) + hub.Broadcast(msg) + }() + + w.WriteHeader(http.StatusAccepted) + } +} diff --git a/handlers/upload.go b/handlers/upload.go new file mode 100644 index 0000000..91fa7af --- /dev/null +++ b/handlers/upload.go @@ -0,0 +1,58 @@ +package handlers + +import ( + "context" + "encoding/json" + "io" + "net/http" + "path/filepath" + + "aroll/store" +) + +// UploadHandler reads the video into memory and stores it in Redis under a +// random key. The key is returned to the frontend as "filename". +// Nothing is written to disk — the file lives only in Redis with a TTL. +func UploadHandler(st *store.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 4<<30) + if err := r.ParseMultipartForm(64 << 20); err != nil { + http.Error(w, "parse form: "+err.Error(), http.StatusBadRequest) + return + } + + file, header, err := r.FormFile("video") + if err != nil { + http.Error(w, "missing 'video' field", http.StatusBadRequest) + return + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + http.Error(w, "read file: "+err.Error(), http.StatusInternalServerError) + return + } + + ext := filepath.Ext(header.Filename) + if ext == "" { + ext = ".mp4" + } + + // Key format: "video-" e.g. "video-a3f9...2b.mp4" + key := "video-" + store.NewID() + ext + + if err := st.Set(context.Background(), key, data); err != nil { + http.Error(w, "store: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"filename": key}) + } +} diff --git a/handlers/ws.go b/handlers/ws.go new file mode 100644 index 0000000..c956db1 --- /dev/null +++ b/handlers/ws.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "log" + "net/http" + + "aroll/ws" + + gws "github.com/gorilla/websocket" +) + +var upgrader = gws.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 4096, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +func WSHandler(hub *ws.Hub) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("ws upgrade: %v", err) + return + } + + client := &ws.Client{Send: make(chan []byte, 256)} + hub.Register <- client + + // Writer goroutine: pumps messages from hub → WebSocket + go func() { + defer conn.Close() + for msg := range client.Send { + if err := conn.WriteMessage(gws.TextMessage, msg); err != nil { + return + } + } + }() + + // Reader loop: keeps the connection alive and detects close frames + for { + if _, _, err := conn.ReadMessage(); err != nil { + break + } + } + hub.Unregister <- client + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..39bb802 --- /dev/null +++ b/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "log" + "net/http" + "os" + + "aroll/handlers" + "aroll/store" + "aroll/ws" +) + +func main() { + redisAddr := "localhost:6379" + if addr := os.Getenv("REDIS_ADDR"); addr != "" { + redisAddr = addr + } + + st, err := store.New(redisAddr) + if err != nil { + log.Fatalf("redis: %v", err) + } + log.Printf("Connected to Redis at %s", redisAddr) + + hub := ws.NewHub() + go hub.Run() + + mux := http.NewServeMux() + + mux.HandleFunc("/ws", handlers.WSHandler(hub)) + mux.HandleFunc("/upload", handlers.UploadHandler(st)) + mux.HandleFunc("/analyze", handlers.AnalyzeHandler(st, hub)) + mux.HandleFunc("/export", handlers.ExportHandler(st, hub)) + mux.Handle("/download/", http.StripPrefix("/download/", handlers.DownloadHandler(st))) + + // Serve the React build in production + if _, err := os.Stat("frontend/dist"); err == nil { + mux.Handle("/", http.FileServer(http.Dir("frontend/dist"))) + } + + log.Println("Listening on http://localhost:8080") + log.Fatal(http.ListenAndServe(":8080", mux)) +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..43aecba --- /dev/null +++ b/store/store.go @@ -0,0 +1,48 @@ +package store + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +// TTL is how long any stored file lives in Redis before automatic deletion. +const TTL = 2 * time.Hour + +type Store struct { + rdb *redis.Client +} + +func New(addr string) (*Store, error) { + rdb := redis.NewClient(&redis.Options{Addr: addr}) + if err := rdb.Ping(context.Background()).Err(); err != nil { + return nil, fmt.Errorf("redis connect %s: %w", addr, err) + } + return &Store{rdb: rdb}, nil +} + +// Set stores binary data under key with the global TTL. +func (s *Store) Set(ctx context.Context, key string, data []byte) error { + return s.rdb.Set(ctx, key, data, TTL).Err() +} + +// Get retrieves binary data by key. +func (s *Store) Get(ctx context.Context, key string) ([]byte, error) { + return s.rdb.Get(ctx, key).Bytes() +} + +// Delete removes a key immediately (called after download). +func (s *Store) Delete(ctx context.Context, key string) { + s.rdb.Del(ctx, key) +} + +// NewID generates a random hex ID for use as a Redis key suffix. +func NewID() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/transcode/transcode.go b/transcode/transcode.go new file mode 100644 index 0000000..2ecdcb0 --- /dev/null +++ b/transcode/transcode.go @@ -0,0 +1,206 @@ +package transcode + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "os/exec" + "regexp" + "strconv" +) + +// Segment is a time interval [Start, End) in seconds. +type Segment struct { + Start float64 `json:"start"` + End float64 `json:"end"` +} + +type wsMsg struct { + Type string `json:"type"` + Segments []Segment `json:"segments,omitempty"` + Duration float64 `json:"duration,omitempty"` + Percent float64 `json:"percent,omitempty"` + Message string `json:"message,omitempty"` +} + +func send(msg wsMsg, broadcast func([]byte)) { + data, _ := json.Marshal(msg) + broadcast(data) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Silence detection +// ────────────────────────────────────────────────────────────────────────────── + +// DetectSpeechSegments runs ffmpeg's silencedetect filter and returns the +// non-silent (speech) segments. A "segments" WebSocket message is broadcast +// when detection finishes. +func DetectSpeechSegments( + inputPath string, + noiseDb, minDuration, padding float64, + broadcast func([]byte), +) ([]Segment, error) { + filter := fmt.Sprintf("silencedetect=noise=%.0fdB:d=%.2f", noiseDb, minDuration) + + cmd := exec.Command("ffmpeg", + "-i", inputPath, + "-af", filter, + "-f", "null", "-", + ) + + // ffmpeg writes silencedetect output to stderr + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("ffmpeg start: %w", err) + } + + var ( + silences []silenceInterval + pendingStart float64 + totalDuration float64 + + durationRe = regexp.MustCompile(`Duration:\s+(\d+):(\d+):([0-9.]+)`) + startRe = regexp.MustCompile(`silence_start:\s*([0-9.e+\-]+)`) + endRe = regexp.MustCompile(`silence_end:\s*([0-9.e+\-]+)`) + ) + + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + line := scanner.Text() + + if m := durationRe.FindStringSubmatch(line); m != nil { + h, _ := strconv.ParseFloat(m[1], 64) + min, _ := strconv.ParseFloat(m[2], 64) + s, _ := strconv.ParseFloat(m[3], 64) + totalDuration = h*3600 + min*60 + s + } + if m := startRe.FindStringSubmatch(line); m != nil { + pendingStart, _ = strconv.ParseFloat(m[1], 64) + } + if m := endRe.FindStringSubmatch(line); m != nil { + end, _ := strconv.ParseFloat(m[1], 64) + silences = append(silences, silenceInterval{start: pendingStart, end: end}) + } + } + + if err := cmd.Wait(); err != nil { + return nil, fmt.Errorf("ffmpeg: %w", err) + } + + segments := invertSilences(silences, totalDuration, padding) + + send(wsMsg{ + Type: "segments", + Segments: segments, + Duration: totalDuration, + }, broadcast) + + return segments, nil +} + +type silenceInterval struct{ start, end float64 } + +// invertSilences turns silence regions into speech regions, with a small +// padding buffer so words at the edges don't get clipped. +func invertSilences(silences []silenceInterval, totalDuration, padding float64) []Segment { + if len(silences) == 0 { + return []Segment{{Start: 0, End: totalDuration}} + } + + var segments []Segment + cursor := 0.0 + + for _, s := range silences { + segEnd := s.start + padding + if segEnd > totalDuration { + segEnd = totalDuration + } + if segEnd-cursor > 0.05 { + segments = append(segments, Segment{Start: cursor, End: segEnd}) + } + next := s.end - padding + if next < segEnd { + next = segEnd + } + cursor = next + } + + if cursor < totalDuration-0.01 { + segments = append(segments, Segment{Start: cursor, End: totalDuration}) + } + + return segments +} + +// ────────────────────────────────────────────────────────────────────────────── +// Export +// ────────────────────────────────────────────────────────────────────────────── + +// ExportSegments concatenates the given segments using ffmpeg's concat demuxer +// (stream-copy, no re-encode). Progress is streamed via broadcast. +func ExportSegments( + inputPath, outputPath string, + segments []Segment, + broadcast func([]byte), +) error { + concatPath := outputPath + ".concat.txt" + f, err := os.Create(concatPath) + if err != nil { + return fmt.Errorf("create concat file: %w", err) + } + defer os.Remove(concatPath) + + totalDuration := 0.0 + for _, seg := range segments { + totalDuration += seg.End - seg.Start + fmt.Fprintf(f, "file '%s'\ninpoint %.6f\noutpoint %.6f\n", + inputPath, seg.Start, seg.End) + } + f.Close() + + cmd := exec.Command("ffmpeg", + "-f", "concat", + "-safe", "0", + "-i", concatPath, + "-c", "copy", + "-progress", "pipe:1", + "-y", + outputPath, + ) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + return fmt.Errorf("ffmpeg start: %w", err) + } + + timeRe := regexp.MustCompile(`out_time_ms=(\d+)`) + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + if m := timeRe.FindStringSubmatch(scanner.Text()); m != nil { + ms, err := strconv.ParseFloat(m[1], 64) + if err != nil || ms < 0 { + continue + } + pct := (ms / 1e6) / totalDuration * 100 + if pct > 100 { + pct = 100 + } + send(wsMsg{Type: "progress", Percent: pct}, broadcast) + } + } + + if err := cmd.Wait(); err != nil { + return fmt.Errorf("ffmpeg: %w", err) + } + + return nil +} diff --git a/ws/hub.go b/ws/hub.go new file mode 100644 index 0000000..d6ddea9 --- /dev/null +++ b/ws/hub.go @@ -0,0 +1,66 @@ +package ws + +import "sync" + +// Client represents a single WebSocket connection. +// The handler owns the actual *websocket.Conn; the hub only needs the send channel. +type Client struct { + Send chan []byte +} + +// Hub manages all active clients and routes broadcast messages to them. +type Hub struct { + mu sync.Mutex + clients map[*Client]struct{} + Register chan *Client + Unregister chan *Client + broadcast chan []byte +} + +func NewHub() *Hub { + return &Hub{ + clients: make(map[*Client]struct{}), + Register: make(chan *Client, 8), + Unregister: make(chan *Client, 8), + broadcast: make(chan []byte, 512), + } +} + +func (h *Hub) Run() { + for { + select { + case c := <-h.Register: + h.mu.Lock() + h.clients[c] = struct{}{} + h.mu.Unlock() + + case c := <-h.Unregister: + h.mu.Lock() + if _, ok := h.clients[c]; ok { + delete(h.clients, c) + close(c.Send) + } + h.mu.Unlock() + + case msg := <-h.broadcast: + h.mu.Lock() + for c := range h.clients { + select { + case c.Send <- msg: + default: + delete(h.clients, c) + close(c.Send) + } + } + h.mu.Unlock() + } + } +} + +// Broadcast sends msg to every connected client (non-blocking). +func (h *Hub) Broadcast(msg []byte) { + select { + case h.broadcast <- msg: + default: + } +}