From ad6587978aa023a3220de515f6fe594743383fa4 Mon Sep 17 00:00:00 2001 From: JorySeverijnse Date: Sun, 21 Dec 2025 13:19:06 +0100 Subject: [PATCH] Correct files to integrate with the site for a good audio to a/v osciloscope converter Now has to be only implemented in the actual website --- package-lock.json | 1867 +++++++++++++++++++++++- package.json | 6 +- src/components/AudioUploader.tsx | 78 + src/components/ControlPanel.tsx | 410 ++---- src/components/Oscilloscope.tsx | 119 -- src/components/OscilloscopeDisplay.tsx | 259 ++++ src/components/OscilloscopeScreen.tsx | 295 ---- src/hooks/useAudioAnalyzer.ts | 277 +--- src/hooks/useOfflineVideoExport.ts | 717 ++++----- src/hooks/useOscilloscopeRenderer.ts | 420 ++++++ src/hooks/useVideoExporter.ts | 526 +++++++ src/pages/Index.tsx | 2 + src/pages/Oscilloscope.tsx | 78 +- videoExportTestApi.ts | 454 ++++++ 14 files changed, 4124 insertions(+), 1384 deletions(-) create mode 100755 src/components/AudioUploader.tsx mode change 100644 => 100755 src/components/ControlPanel.tsx delete mode 100644 src/components/Oscilloscope.tsx create mode 100755 src/components/OscilloscopeDisplay.tsx delete mode 100644 src/components/OscilloscopeScreen.tsx mode change 100644 => 100755 src/hooks/useAudioAnalyzer.ts create mode 100755 src/hooks/useOscilloscopeRenderer.ts create mode 100755 src/hooks/useVideoExporter.ts create mode 100755 videoExportTestApi.ts diff --git a/package-lock.json b/package-lock.json index db10636..314952f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,13 +73,17 @@ "eslint": "^9.32.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", + "express": "^5.2.1", + "gif.js": "^0.2.0", "globals": "^15.15.0", "lovable-tagger": "^1.1.11", "postcss": "^8.5.6", + "puppeteer": "^24.34.0", "tailwindcss": "^3.4.17", "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", - "vite": "^7.2.6" + "vite": "^7.2.6", + "webm-writer": "^1.0.0" } }, "node_modules/@alloc/quick-lru": { @@ -94,6 +98,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", @@ -105,9 +124,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "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": { @@ -946,6 +965,28 @@ "node": ">=14" } }, + "node_modules/@puppeteer/browsers": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.11.0.tgz", + "integrity": "sha512-n6oQX6mYkG8TRPuPXmbPidkUbsSRalhmaaVAQxvH1IkQy63cwsH+kOjB3e4cpCDHg0aSvsiX9bQ4s2VB6mGWUQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -2984,6 +3025,13 @@ "react": "^18 || ^19" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", @@ -3119,6 +3167,17 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.38.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", @@ -3391,6 +3450,20 @@ "vite": "^4 || ^5 || ^6 || ^7" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3414,6 +3487,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3502,6 +3585,19 @@ "node": ">=10" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -3546,6 +3642,113 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", + "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3558,6 +3761,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3614,6 +3842,57 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3737,6 +4016,20 @@ "node": ">= 6" } }, + "node_modules/chromium-bidi": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-12.0.1.tgz", + "integrity": "sha512-fGg+6jr0xjQhzpy5N4ErZxQ4wF7KLEvhGZXD6EgvZKDhu7iOhZXnZhcDxPJDcwTcrD48NPzOCo84RP2lv3Z+Cg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -3748,6 +4041,84 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3823,6 +4194,77 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3975,6 +4417,16 @@ "node": ">=12" } }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/date-fns": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", @@ -3986,9 +4438,9 @@ } }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -4029,12 +4481,44 @@ "dev": true, "license": "MIT" }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devtools-protocol": { + "version": "0.0.1534754", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", + "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -4057,12 +4541,34 @@ "csstype": "^3.0.2" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.192", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.192.tgz", @@ -4104,6 +4610,79 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -4155,6 +4734,13 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4168,6 +4754,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "9.32.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", @@ -4300,6 +4908,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -4356,12 +4978,97 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4378,6 +5085,13 @@ "node": ">=6.0.0" } }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -4442,6 +5156,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4467,6 +5191,28 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -4529,6 +5275,16 @@ "node": ">=0.4.x" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -4570,6 +5326,16 @@ } } }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4593,6 +5359,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -4602,6 +5403,58 @@ "node": ">=6" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gif.js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/gif.js/-/gif.js-0.2.0.tgz", + "integrity": "sha512-bYxCoT8OZKmbxY8RN4qDiYuj4nrQDTzgLRcFVovyona1PTWNePzI4nzOmotnlOFIzTk/ZxAHtv+TfVLiBWj/hw==", + "dev": true, + "license": "MIT" + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -4671,6 +5524,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -4688,6 +5554,19 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4745,6 +5624,72 @@ "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", "license": "CC0-1.0" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4782,6 +5727,13 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/input-otp": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", @@ -4801,6 +5753,26 @@ "node": ">=12" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -4825,6 +5797,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4911,6 +5890,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", @@ -4976,6 +5962,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5140,6 +6133,39 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5162,6 +6188,33 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5184,6 +6237,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, "node_modules/motion-dom": { "version": "12.23.23", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", @@ -5242,6 +6302,26 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/next-themes": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.3.0.tgz", @@ -5296,6 +6376,42 @@ "node": ">= 6" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5346,6 +6462,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -5390,6 +6540,35 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5431,6 +6610,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5629,6 +6826,16 @@ "node": ">=6" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5656,6 +6863,68 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5666,6 +6935,63 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "24.34.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.34.0.tgz", + "integrity": "sha512-Sdpl/zsYOsagZ4ICoZJPGZw8d9gZmK5DcxVal11dXi/1/t2eIXHjCf5NfmhDg5XnG9Nye+yo/LqMzIxie2rHTw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.11.0", + "chromium-bidi": "12.0.1", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1534754", + "puppeteer-core": "24.34.0", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.34.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.34.0.tgz", + "integrity": "sha512-24evawO+mUGW4mvS2a2ivwLdX3gk8zRLZr9HP+7+VT2vBQnm0oh9jJEZmUE3ePJhRkYlZ93i7OMpdcoi2qNCLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.11.0", + "chromium-bidi": "12.0.1", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1534754", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.3.10", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5686,6 +7012,32 @@ ], "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -5978,6 +7330,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -6056,6 +7418,23 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6079,6 +7458,13 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -6089,9 +7475,9 @@ } }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -6101,6 +7487,60 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6122,6 +7562,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6134,6 +7650,47 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/sonner": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", @@ -6144,6 +7701,17 @@ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6163,6 +7731,28 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -6375,6 +7965,73 @@ "tailwindcss": ">=3.0.0 || insiders" } }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tar-stream/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-decoder/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -6459,6 +8116,16 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -6497,6 +8164,28 @@ "node": ">= 0.8.0" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -6542,6 +8231,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -6641,6 +8340,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vaul": { "version": "0.9.9", "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz", @@ -6788,6 +8497,20 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.10.tgz", + "integrity": "sha512-5LAE43jAVLOhB/QqX4bwSiv0Hg1HBfMmOuwBSXHdvg4GMGu9Y0lIq7p4R/yySu6w74WmaR4GM4H9t2IwLW7hgw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/webm-writer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/webm-writer/-/webm-writer-1.0.0.tgz", + "integrity": "sha512-xafP4mzUqht03HBXP0Ov2YGsxfD08uncad9fQeshYwQXrcP6Z/4uxd1IUaGKqKigFPAgaD9xb6JEKA8SXLQMLA==", + "dev": true, + "license": "WTFPL" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6901,6 +8624,45 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yaml": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", @@ -6913,6 +8675,91 @@ "node": ">= 14" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index b78094f..4307ff5 100644 --- a/package.json +++ b/package.json @@ -76,12 +76,16 @@ "eslint": "^9.32.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", + "express": "^5.2.1", + "gif.js": "^0.2.0", "globals": "^15.15.0", "lovable-tagger": "^1.1.11", "postcss": "^8.5.6", + "puppeteer": "^24.34.0", "tailwindcss": "^3.4.17", "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", - "vite": "^7.2.6" + "vite": "^7.2.6", + "webm-writer": "^1.0.0" } } diff --git a/src/components/AudioUploader.tsx b/src/components/AudioUploader.tsx new file mode 100755 index 0000000..89648ff --- /dev/null +++ b/src/components/AudioUploader.tsx @@ -0,0 +1,78 @@ +import { useCallback } from 'react'; +import { Upload, Music } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface AudioUploaderProps { + onFileSelect: (file: File) => void; + isLoading: boolean; + fileName: string | null; +} + +export function AudioUploader({ onFileSelect, isLoading, fileName }: AudioUploaderProps) { + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files[0]; + if (file && file.type.startsWith('audio/')) { + onFileSelect(file); + } + }, [onFileSelect]); + + const handleFileInput = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + onFileSelect(file); + } + }, [onFileSelect]); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + return ( +
+ + +
+ {fileName ? ( + <> + +
+

{fileName}

+

Click or drop to replace

+
+ + ) : ( + <> + +
+

Drop audio file here

+

or click to browse

+

MP3, WAV, FLAC, OGG supported

+
+ + )} + + {isLoading && ( +
+
+
+ )} +
+
+ ); +} diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx old mode 100644 new mode 100755 index 01aa767..fe847c4 --- a/src/components/ControlPanel.tsx +++ b/src/components/ControlPanel.tsx @@ -1,308 +1,142 @@ -import { useRef, useState } from 'react'; +import { Play, Download, RotateCcw } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Slider } from '@/components/ui/slider'; -import { Mic, Radio, Move, Upload, Play, Pause, Square, Music, Video, Download, X } from 'lucide-react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; import { Progress } from '@/components/ui/progress'; -import type { ExportStage } from '@/hooks/useOfflineVideoExport'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import type { OscilloscopeMode } from '@/hooks/useOscilloscopeRenderer'; interface ControlPanelProps { - mode: 'normal' | 'xy'; - onModeChange: (mode: 'normal' | 'xy') => void; - isActive: boolean; + mode: OscilloscopeMode; + onModeChange: (mode: OscilloscopeMode) => void; + canGenerate: boolean; + isGenerating: boolean; + progress: number; + exportedUrl: string | null; + onGenerate: () => void; + onReset: () => void; isPlaying: boolean; - source: 'microphone' | 'file' | null; - fileName: string | null; - onStartMicrophone: () => void; - onLoadAudioFile: (file: File) => void; - onTogglePlayPause: () => void; - onStop: () => void; - onGainChange: (value: number) => void; - error: string | null; - isExporting: boolean; - exportProgress: number; - exportStage: ExportStage; - exportFps: number; - onExportVideo: (format: 'webm' | 'mp4') => void; - onCancelExport: () => void; + onPreview: () => void; + canPreview: boolean; } -export const ControlPanel = ({ +export function ControlPanel({ mode, onModeChange, - isActive, + canGenerate, + isGenerating, + progress, + exportedUrl, + onGenerate, + onReset, isPlaying, - source, - fileName, - onStartMicrophone, - onLoadAudioFile, - onTogglePlayPause, - onStop, - onGainChange, - error, - isExporting, - exportProgress, - exportStage, - exportFps, - onExportVideo, - onCancelExport, -}: ControlPanelProps) => { - const fileInputRef = useRef(null); - const [showExportDialog, setShowExportDialog] = useState(false); - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - onLoadAudioFile(file); - } - }; - - const handleExportClick = () => { - if (isExporting) { - onCancelExport(); - } else { - setShowExportDialog(true); - } - }; - - const handleFormatSelect = (format: 'webm' | 'mp4') => { - setShowExportDialog(false); - onExportVideo(format); - }; - + onPreview, + canPreview, +}: ControlPanelProps) { return ( - <> -
- {/* Status indicator */} -
-
- - {isExporting ? 'Exporting' : isActive ? (source === 'microphone' ? 'Mic Active' : 'Playing') : 'Standby'} - - {isExporting && ( -
- )} -
- - {/* Input Source */} -
- -
- - - - +
+ {/* Mode Selection */} +
+ + onModeChange(value as OscilloscopeMode)} + className="space-y-2" + > +
+ +
-
- - {/* File name display */} - {fileName && ( -
- - {fileName} +
+ +
- )} - - {/* Playback controls */} - {isActive && !isExporting && ( -
- {source === 'file' && ( - - )} - +
+ +
- )} - - {/* Video Export */} - {source === 'file' && ( -
- - - {isExporting && ( -
- -

- {exportStage === 'preparing' && 'Preparing audio...'} - {exportStage === 'rendering' && `Rendering: ${exportProgress}% ${exportFps > 0 ? `(${exportFps} fps)` : ''}`} - {exportStage === 'encoding' && 'Encoding final video...'} - {exportStage === 'complete' && 'Finalizing...'} -

-
- )} - {!isExporting && ( -

- Generates video from the entire audio file offline. -

- )} -
- )} - - {/* Sensitivity / Gain control */} -
- - onGainChange(value[0])} - className="w-full" - disabled={isExporting} - /> -

- Increase for quiet audio sources -

-
- - {/* Mode selector */} -
- -
- - -
-
- - {/* Mode description */} -
-

- {mode === 'normal' - ? 'Time-domain waveform display. Shows amplitude over time.' - : 'Lissajous (X-Y) mode. Left channel controls X, Right controls Y. Creates patterns from stereo audio.'} -

-
- - {/* Error display */} - {error && ( -
-

{error}

-
- )} - - {/* Info */} -
-

- Audio Oscilloscope v1.3 -

-
+
- {/* Export Format Dialog */} - - - - Choose Export Format - - The video will be generated from the entire audio file. This works offline and supports large files. - - -
+ {/* Preview Button */} + + + {/* Generate Button */} + + + {/* Progress Bar */} + {isGenerating && ( +
+ +

+ {progress}% complete +

+

+ Keep this tab in foreground +

+
+ )} + + {/* Download Button */} + {exportedUrl && ( + - -
- + + + +
+ )} + + {/* Info */} +
+

Output: 1920×1080 WebM

+

Frame Rate: 60 FPS

+

Supports files up to 6+ hours

+
+
); -}; +} diff --git a/src/components/Oscilloscope.tsx b/src/components/Oscilloscope.tsx deleted file mode 100644 index 1270da6..0000000 --- a/src/components/Oscilloscope.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { useState, useRef, useCallback } from 'react'; -import { OscilloscopeScreen, OscilloscopeScreenHandle } from './OscilloscopeScreen'; -import { ControlPanel } from './ControlPanel'; -import { useAudioAnalyzer } from '@/hooks/useAudioAnalyzer'; -import { useOfflineVideoExport } from '@/hooks/useOfflineVideoExport'; -import { toast } from 'sonner'; - -export const Oscilloscope = () => { - const [mode, setMode] = useState<'normal' | 'xy'>('normal'); - const screenRef = useRef(null); - const audioFileRef = useRef(null); - - const { - isActive, - isPlaying, - source, - fileName, - error, - startMicrophone, - loadAudioFile, - togglePlayPause, - stop, - setGain, - getTimeDomainData, - getStereoData, - } = useAudioAnalyzer(); - - const { - isExporting, - progress, - stage, - fps: exportFps, - generateVideoWithAudio, - cancelExport, - downloadBlob, - } = useOfflineVideoExport(); - - const handleLoadAudioFile = useCallback((file: File) => { - audioFileRef.current = file; - loadAudioFile(file); - }, [loadAudioFile]); - - const handleExportVideo = useCallback(async (format: 'webm' | 'mp4') => { - if (!audioFileRef.current) { - toast.error('Please load an audio file first'); - return; - } - - const drawFrame = screenRef.current?.drawFrameWithData; - if (!drawFrame) { - toast.error('Canvas not ready'); - return; - } - - toast.info('Starting video export... This may take a while for large files.'); - - const blob = await generateVideoWithAudio( - audioFileRef.current, - drawFrame, - { - fps: 60, - format, - width: 1920, - height: 1080, - } - ); - - if (blob) { - const baseName = fileName?.replace(/\.[^/.]+$/, '') || 'oscilloscope'; - const extension = format === 'mp4' ? 'mp4' : 'webm'; - downloadBlob(blob, `${baseName}.${extension}`); - toast.success('Video exported successfully!'); - } - }, [fileName, generateVideoWithAudio, downloadBlob]); - - return ( -
- {/* Main oscilloscope display */} -
-
- {/* Screen bezel */} -
- -
-
-
- - {/* Control panel */} -
- -
-
- ); -}; diff --git a/src/components/OscilloscopeDisplay.tsx b/src/components/OscilloscopeDisplay.tsx new file mode 100755 index 0000000..7a6c5ca --- /dev/null +++ b/src/components/OscilloscopeDisplay.tsx @@ -0,0 +1,259 @@ +import { useEffect, useRef, useCallback } from 'react'; +import type { AudioData } from '@/hooks/useAudioAnalyzer'; +import type { OscilloscopeMode } from '@/hooks/useOscilloscopeRenderer'; + +interface OscilloscopeDisplayProps { + audioData: AudioData | null; + mode: OscilloscopeMode; + isPlaying: boolean; + onPlaybackEnd?: () => void; +} + +const WIDTH = 800; +const HEIGHT = 600; +const FPS = 60; + +export function OscilloscopeDisplay({ + audioData, + mode, + isPlaying, + onPlaybackEnd +}: OscilloscopeDisplayProps) { + const canvasRef = useRef(null); + const animationRef = useRef(null); + const currentSampleRef = useRef(0); + + const drawGraticule = useCallback((ctx: CanvasRenderingContext2D) => { + ctx.strokeStyle = '#00ff00'; + ctx.lineWidth = 1; + + // Horizontal center line (X axis) + ctx.beginPath(); + ctx.moveTo(0, HEIGHT / 2); + ctx.lineTo(WIDTH, HEIGHT / 2); + ctx.stroke(); + + // Vertical center line (Y axis) + ctx.beginPath(); + ctx.moveTo(WIDTH / 2, 0); + ctx.lineTo(WIDTH / 2, HEIGHT); + ctx.stroke(); + }, []); + + const drawFrame = useCallback(() => { + if (!audioData || !canvasRef.current) return; + + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const samplesPerFrame = Math.floor(audioData.sampleRate / FPS); + const startSample = currentSampleRef.current; + const endSample = Math.min(startSample + samplesPerFrame, audioData.leftChannel.length); + + // Clear to pure black + ctx.fillStyle = '#000000'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + + // Draw graticule first + drawGraticule(ctx); + + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + + const leftColor = '#00ff00'; + const rightColor = '#00ccff'; + const xyColor = '#ff8800'; + const dividerColor = '#333333'; + + if (mode === 'combined') { + // Combined: both channels merged + ctx.strokeStyle = leftColor; + ctx.beginPath(); + const samplesPerPixel = samplesPerFrame / WIDTH; + const centerY = HEIGHT / 2; + for (let x = 0; x < WIDTH; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= audioData.leftChannel.length) break; + const sample = (audioData.leftChannel[sampleIndex] + audioData.rightChannel[sampleIndex]) / 2; + const y = centerY - sample * (HEIGHT * 0.4); + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } else if (mode === 'separate') { + // Separate: Left on top, Right on bottom + const halfHeight = HEIGHT / 2; + const samplesPerPixel = samplesPerFrame / WIDTH; + + // Left channel (top) + ctx.strokeStyle = leftColor; + ctx.beginPath(); + const leftCenterY = halfHeight / 2; + for (let x = 0; x < WIDTH; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= audioData.leftChannel.length) break; + const sample = audioData.leftChannel[sampleIndex]; + const y = leftCenterY - sample * (halfHeight * 0.35); + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Right channel (bottom) + ctx.strokeStyle = rightColor; + ctx.beginPath(); + const rightCenterY = halfHeight + halfHeight / 2; + for (let x = 0; x < WIDTH; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= audioData.rightChannel.length) break; + const sample = audioData.rightChannel[sampleIndex]; + const y = rightCenterY - sample * (halfHeight * 0.35); + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Divider + ctx.strokeStyle = dividerColor; + ctx.beginPath(); + ctx.moveTo(0, halfHeight); + ctx.lineTo(WIDTH, halfHeight); + ctx.stroke(); + } else if (mode === 'all') { + // All: L/R on top row, XY on bottom + const topHeight = HEIGHT / 2; + const bottomHeight = HEIGHT / 2; + const halfWidth = WIDTH / 2; + const samplesPerPixel = samplesPerFrame / halfWidth; + + // Left channel (top-left) + ctx.strokeStyle = leftColor; + ctx.beginPath(); + const leftCenterY = topHeight / 2; + for (let x = 0; x < halfWidth; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= audioData.leftChannel.length) break; + const sample = audioData.leftChannel[sampleIndex]; + const y = leftCenterY - sample * (topHeight * 0.35); + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Right channel (top-right) + ctx.strokeStyle = rightColor; + ctx.beginPath(); + const rightCenterY = topHeight / 2; + for (let x = 0; x < halfWidth; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= audioData.rightChannel.length) break; + const sample = audioData.rightChannel[sampleIndex]; + const y = rightCenterY - sample * (topHeight * 0.35); + if (x === 0) ctx.moveTo(halfWidth + x, y); + else ctx.lineTo(halfWidth + x, y); + } + ctx.stroke(); + + // XY mode (bottom half) + ctx.strokeStyle = xyColor; + ctx.beginPath(); + const xyCenterX = WIDTH / 2; + const xyCenterY = topHeight + bottomHeight / 2; + const xyScale = Math.min(halfWidth, bottomHeight) * 0.35; + for (let i = startSample; i < endSample; i++) { + const x = xyCenterX + audioData.leftChannel[i] * xyScale; + const y = xyCenterY - audioData.rightChannel[i] * xyScale; + if (i === startSample) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Dividers + ctx.strokeStyle = dividerColor; + ctx.beginPath(); + ctx.moveTo(0, topHeight); + ctx.lineTo(WIDTH, topHeight); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(halfWidth, 0); + ctx.lineTo(halfWidth, topHeight); + ctx.stroke(); + } + + currentSampleRef.current = endSample; + + if (endSample >= audioData.leftChannel.length) { + onPlaybackEnd?.(); + return; + } + + animationRef.current = requestAnimationFrame(drawFrame); + }, [audioData, mode, drawGraticule, onPlaybackEnd]); + + // Initialize canvas + useEffect(() => { + if (!canvasRef.current) return; + + const ctx = canvasRef.current.getContext('2d'); + if (ctx) { + ctx.fillStyle = '#000000'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + drawGraticule(ctx); + } + }, [drawGraticule]); + + // Handle playback + useEffect(() => { + if (isPlaying && audioData) { + currentSampleRef.current = 0; + animationRef.current = requestAnimationFrame(drawFrame); + } else { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + } + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + }, [isPlaying, audioData, drawFrame]); + + const getModeLabel = () => { + switch (mode) { + case 'combined': return 'L+R'; + case 'separate': return 'L / R'; + case 'all': return 'ALL'; + default: return ''; + } + }; + + return ( +
+
+ + + {/* Mode indicator */} +
+ {getModeLabel()} +
+ + {/* Idle state */} + {!audioData && !isPlaying && ( +
+

+ NO SIGNAL +

+
+ )} +
+
+ ); +} diff --git a/src/components/OscilloscopeScreen.tsx b/src/components/OscilloscopeScreen.tsx deleted file mode 100644 index d75caaa..0000000 --- a/src/components/OscilloscopeScreen.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import { useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'; - -interface OscilloscopeScreenProps { - mode: 'normal' | 'xy'; - getTimeDomainData: () => Uint8Array | null; - getStereoData: () => { left: Uint8Array; right: Uint8Array } | null; - isActive: boolean; -} - -export interface OscilloscopeScreenHandle { - getCanvas: () => HTMLCanvasElement | null; - drawFrameWithData: (ctx: CanvasRenderingContext2D, width: number, height: number, leftData: Uint8Array, rightData: Uint8Array) => void; -} - -export const OscilloscopeScreen = forwardRef(({ - mode, - getTimeDomainData, - getStereoData, - isActive, -}, ref) => { - const canvasRef = useRef(null); - const animationRef = useRef(); - const lastTimeRef = useRef(0); - const targetFPS = 120; - const frameInterval = 1000 / targetFPS; - - const drawGrid = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number) => { - ctx.strokeStyle = '#1a3a1a'; - ctx.lineWidth = 1; - - const vDivisions = 10; - for (let i = 0; i <= vDivisions; i++) { - const x = Math.round((width / vDivisions) * i) + 0.5; - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, height); - ctx.stroke(); - } - - const hDivisions = 8; - for (let i = 0; i <= hDivisions; i++) { - const y = Math.round((height / hDivisions) * i) + 0.5; - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(width, y); - ctx.stroke(); - } - - ctx.strokeStyle = '#2a5a2a'; - ctx.lineWidth = 1; - - const centerX = Math.round(width / 2) + 0.5; - const centerY = Math.round(height / 2) + 0.5; - const tickLength = 6; - const tickSpacing = width / 50; - - ctx.beginPath(); - ctx.moveTo(centerX, 0); - ctx.lineTo(centerX, height); - ctx.stroke(); - - ctx.beginPath(); - ctx.moveTo(0, centerY); - ctx.lineTo(width, centerY); - ctx.stroke(); - - ctx.strokeStyle = '#2a5a2a'; - for (let i = 0; i < 50; i++) { - const x = Math.round(i * tickSpacing) + 0.5; - const y = Math.round(i * tickSpacing * (height / width)) + 0.5; - - ctx.beginPath(); - ctx.moveTo(x, centerY - tickLength / 2); - ctx.lineTo(x, centerY + tickLength / 2); - ctx.stroke(); - - if (y < height) { - ctx.beginPath(); - ctx.moveTo(centerX - tickLength / 2, y); - ctx.lineTo(centerX + tickLength / 2, y); - ctx.stroke(); - } - } - }, []); - - const drawNormalMode = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number, data: Uint8Array) => { - const centerY = height / 2; - const points: { x: number; y: number }[] = []; - - const step = Math.max(1, Math.floor(data.length / (width * 2))); - - for (let i = 0; i < data.length; i += step) { - const x = (i / data.length) * width; - const normalizedValue = (data[i] - 128) / 128; - const y = centerY - (normalizedValue * (height / 2) * 0.85); - points.push({ x, y }); - } - - if (points.length < 2) return; - - ctx.strokeStyle = 'rgba(0, 255, 0, 0.15)'; - ctx.lineWidth = 6; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - - ctx.beginPath(); - ctx.moveTo(points[0].x, points[0].y); - - for (let i = 1; i < points.length - 1; i++) { - const xc = (points[i].x + points[i + 1].x) / 2; - const yc = (points[i].y + points[i + 1].y) / 2; - ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc); - } - ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y); - ctx.stroke(); - - ctx.strokeStyle = 'rgba(0, 255, 0, 0.3)'; - ctx.lineWidth = 3; - ctx.stroke(); - - ctx.strokeStyle = '#00ff00'; - ctx.lineWidth = 1.5; - ctx.stroke(); - }, []); - - const drawXYMode = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number, leftData: Uint8Array, rightData: Uint8Array) => { - const centerX = width / 2; - const centerY = height / 2; - const scale = Math.min(width, height) / 2 * 0.85; - const points: { x: number; y: number }[] = []; - - const step = Math.max(1, Math.floor(leftData.length / 2048)); - - for (let i = 0; i < leftData.length; i += step) { - const xNorm = (leftData[i] - 128) / 128; - const yNorm = (rightData[i] - 128) / 128; - - const x = centerX + xNorm * scale; - const y = centerY - yNorm * scale; - points.push({ x, y }); - } - - if (points.length < 2) return; - - ctx.strokeStyle = 'rgba(0, 255, 0, 0.15)'; - ctx.lineWidth = 6; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - - ctx.beginPath(); - ctx.moveTo(points[0].x, points[0].y); - - for (let i = 1; i < points.length - 1; i++) { - const xc = (points[i].x + points[i + 1].x) / 2; - const yc = (points[i].y + points[i + 1].y) / 2; - ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc); - } - ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y); - ctx.stroke(); - - ctx.strokeStyle = 'rgba(0, 255, 0, 0.3)'; - ctx.lineWidth = 3; - ctx.stroke(); - - ctx.strokeStyle = '#00ff00'; - ctx.lineWidth = 1.5; - ctx.stroke(); - }, []); - - const drawIdleWave = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number) => { - const centerY = height / 2; - - ctx.strokeStyle = 'rgba(0, 255, 0, 0.15)'; - ctx.lineWidth = 6; - ctx.lineCap = 'round'; - - ctx.beginPath(); - ctx.moveTo(0, centerY); - ctx.lineTo(width, centerY); - ctx.stroke(); - - ctx.strokeStyle = 'rgba(0, 255, 0, 0.3)'; - ctx.lineWidth = 3; - ctx.stroke(); - - ctx.strokeStyle = '#00ff00'; - ctx.lineWidth = 1.5; - ctx.stroke(); - }, []); - - useImperativeHandle(ref, () => ({ - getCanvas: () => canvasRef.current, - drawFrameWithData: (ctx: CanvasRenderingContext2D, width: number, height: number, leftData: Uint8Array, rightData: Uint8Array) => { - ctx.fillStyle = '#0a0f0a'; - ctx.fillRect(0, 0, width, height); - drawGrid(ctx, width, height); - if (mode === 'normal') { - drawNormalMode(ctx, width, height, leftData); - } else { - drawXYMode(ctx, width, height, leftData, rightData); - } - }, - }), [mode, drawGrid, drawNormalMode, drawXYMode]); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d', { alpha: false }); - if (!ctx) return; - - const render = (currentTime: number) => { - const deltaTime = currentTime - lastTimeRef.current; - - if (deltaTime >= frameInterval) { - lastTimeRef.current = currentTime - (deltaTime % frameInterval); - - const dpr = window.devicePixelRatio || 1; - const width = canvas.width / dpr; - const height = canvas.height / dpr; - - ctx.fillStyle = '#0a0f0a'; - ctx.fillRect(0, 0, width, height); - - drawGrid(ctx, width, height); - - if (isActive) { - if (mode === 'normal') { - const data = getTimeDomainData(); - if (data) { - drawNormalMode(ctx, width, height, data); - } - } else { - const stereoData = getStereoData(); - if (stereoData) { - drawXYMode(ctx, width, height, stereoData.left, stereoData.right); - } - } - } else { - drawIdleWave(ctx, width, height); - } - } - - animationRef.current = requestAnimationFrame(render); - }; - - animationRef.current = requestAnimationFrame(render); - - return () => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current); - } - }; - }, [mode, isActive, getTimeDomainData, getStereoData, drawGrid, drawNormalMode, drawXYMode, drawIdleWave, frameInterval]); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const resizeCanvas = () => { - const container = canvas.parentElement; - if (!container) return; - - const rect = container.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - - canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; - - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.scale(dpr, dpr); - } - - canvas.style.width = `${rect.width}px`; - canvas.style.height = `${rect.height}px`; - }; - - resizeCanvas(); - window.addEventListener('resize', resizeCanvas); - - return () => { - window.removeEventListener('resize', resizeCanvas); - }; - }, []); - - return ( -
- -
- ); -}); diff --git a/src/hooks/useAudioAnalyzer.ts b/src/hooks/useAudioAnalyzer.ts old mode 100644 new mode 100755 index 4491afa..10ff8b9 --- a/src/hooks/useAudioAnalyzer.ts +++ b/src/hooks/useAudioAnalyzer.ts @@ -1,246 +1,69 @@ -import { useState, useRef, useCallback, useEffect } from 'react'; +import { useRef, useState, useCallback } from 'react'; -interface AudioAnalyzerState { - isActive: boolean; - error: string | null; - source: 'microphone' | 'file' | null; - fileName: string | null; - isPlaying: boolean; +export interface AudioData { + leftChannel: Float32Array; + rightChannel: Float32Array; + sampleRate: number; + duration: number; } -export const useAudioAnalyzer = () => { - const [state, setState] = useState({ - isActive: false, - error: null, - source: null, - fileName: null, - isPlaying: false, - }); - +export function useAudioAnalyzer() { + const [audioData, setAudioData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [fileName, setFileName] = useState(null); const audioContextRef = useRef(null); - const analyzerLeftRef = useRef(null); - const analyzerRightRef = useRef(null); - const sourceRef = useRef(null); - const splitterRef = useRef(null); - const streamRef = useRef(null); - const analysisGainNodeRef = useRef(null); - const audioElementRef = useRef(null); - const gainValueRef = useRef(3); // Default higher gain for analysis sensitivity only - - const getTimeDomainData = useCallback(() => { - if (!analyzerLeftRef.current) return null; - - const bufferLength = analyzerLeftRef.current.fftSize; - const dataArray = new Uint8Array(bufferLength); - analyzerLeftRef.current.getByteTimeDomainData(dataArray); - - return dataArray; - }, []); - - const getStereoData = useCallback(() => { - if (!analyzerLeftRef.current || !analyzerRightRef.current) return null; - - const bufferLength = analyzerLeftRef.current.fftSize; - const leftData = new Uint8Array(bufferLength); - const rightData = new Uint8Array(bufferLength); - - analyzerLeftRef.current.getByteTimeDomainData(leftData); - analyzerRightRef.current.getByteTimeDomainData(rightData); - - return { left: leftData, right: rightData }; - }, []); - - const setGain = useCallback((value: number) => { - gainValueRef.current = value; - if (analysisGainNodeRef.current) { - analysisGainNodeRef.current.gain.value = value; - } - }, []); - - const setupAnalyzers = useCallback((audioContext: AudioContext) => { - // Create gain node for analysis sensitivity (does NOT affect audio output) - analysisGainNodeRef.current = audioContext.createGain(); - analysisGainNodeRef.current.gain.value = gainValueRef.current; - - // Create channel splitter for stereo - splitterRef.current = audioContext.createChannelSplitter(2); - - // Create analyzers for each channel - analyzerLeftRef.current = audioContext.createAnalyser(); - analyzerRightRef.current = audioContext.createAnalyser(); - - // Configure analyzers for higher sensitivity - const fftSize = 2048; - analyzerLeftRef.current.fftSize = fftSize; - analyzerRightRef.current.fftSize = fftSize; - analyzerLeftRef.current.smoothingTimeConstant = 0.5; - analyzerRightRef.current.smoothingTimeConstant = 0.5; - analyzerLeftRef.current.minDecibels = -90; - analyzerRightRef.current.minDecibels = -90; - analyzerLeftRef.current.maxDecibels = -10; - analyzerRightRef.current.maxDecibels = -10; - }, []); - - const startMicrophone = useCallback(async () => { - try { - setState(prev => ({ ...prev, isActive: false, error: null })); - - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: false, - noiseSuppression: false, - autoGainControl: false, - }, - }); - - streamRef.current = stream; - audioContextRef.current = new AudioContext(); - - setupAnalyzers(audioContextRef.current); - - // Create source from microphone - const micSource = audioContextRef.current.createMediaStreamSource(stream); - sourceRef.current = micSource; - - // Connect: source -> analysisGain -> splitter -> analyzers - // (microphone doesn't need output, just analysis) - micSource.connect(analysisGainNodeRef.current!); - analysisGainNodeRef.current!.connect(splitterRef.current!); - splitterRef.current!.connect(analyzerLeftRef.current!, 0); - splitterRef.current!.connect(analyzerRightRef.current!, 1); - - setState({ - isActive: true, - error: null, - source: 'microphone', - fileName: null, - isPlaying: true - }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to access microphone'; - setState(prev => ({ ...prev, isActive: false, error: message })); - } - }, [setupAnalyzers]); const loadAudioFile = useCallback(async (file: File) => { + setIsLoading(true); + setError(null); + setFileName(file.name); + try { - // Stop any existing audio - stop(); + // Create or reuse AudioContext + if (!audioContextRef.current) { + audioContextRef.current = new AudioContext(); + } + const audioContext = audioContextRef.current; + + // Read file as ArrayBuffer + const arrayBuffer = await file.arrayBuffer(); - setState(prev => ({ ...prev, isActive: false, error: null })); + // Decode audio data + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + + // Extract channel data + const leftChannel = audioBuffer.getChannelData(0); + const rightChannel = audioBuffer.numberOfChannels > 1 + ? audioBuffer.getChannelData(1) + : audioBuffer.getChannelData(0); // Mono: duplicate left channel - // Create audio element - const audioElement = new Audio(); - audioElement.src = URL.createObjectURL(file); - audioElement.loop = true; - audioElementRef.current = audioElement; - - audioContextRef.current = new AudioContext(); - setupAnalyzers(audioContextRef.current); - - // Create source from audio element - const audioSource = audioContextRef.current.createMediaElementSource(audioElement); - sourceRef.current = audioSource; - - // For files: source -> destination (clean audio output) - // source -> analysisGain -> splitter -> analyzers (boosted for visualization) - audioSource.connect(audioContextRef.current.destination); - audioSource.connect(analysisGainNodeRef.current!); - analysisGainNodeRef.current!.connect(splitterRef.current!); - splitterRef.current!.connect(analyzerLeftRef.current!, 0); - splitterRef.current!.connect(analyzerRightRef.current!, 1); - - // Start playing - await audioElement.play(); - - setState({ - isActive: true, - error: null, - source: 'file', - fileName: file.name, - isPlaying: true + setAudioData({ + leftChannel: new Float32Array(leftChannel), + rightChannel: new Float32Array(rightChannel), + sampleRate: audioBuffer.sampleRate, + duration: audioBuffer.duration, }); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to load audio file'; - setState(prev => ({ ...prev, isActive: false, error: message })); - } - }, [setupAnalyzers]); - - const togglePlayPause = useCallback(() => { - if (!audioElementRef.current) return; - - if (audioElementRef.current.paused) { - audioElementRef.current.play(); - setState(prev => ({ ...prev, isPlaying: true })); - } else { - audioElementRef.current.pause(); - setState(prev => ({ ...prev, isPlaying: false })); + setError(err instanceof Error ? err.message : 'Failed to load audio file'); + setAudioData(null); + } finally { + setIsLoading(false); } }, []); - const stop = useCallback(() => { - if (streamRef.current) { - streamRef.current.getTracks().forEach(track => track.stop()); - streamRef.current = null; - } - - if (audioElementRef.current) { - audioElementRef.current.pause(); - audioElementRef.current.src = ''; - audioElementRef.current = null; - } - - if (sourceRef.current) { - sourceRef.current.disconnect(); - sourceRef.current = null; - } - - if (analysisGainNodeRef.current) { - analysisGainNodeRef.current.disconnect(); - analysisGainNodeRef.current = null; - } - - if (splitterRef.current) { - splitterRef.current.disconnect(); - splitterRef.current = null; - } - - if (audioContextRef.current) { - audioContextRef.current.close(); - audioContextRef.current = null; - } - - analyzerLeftRef.current = null; - analyzerRightRef.current = null; - - setState({ - isActive: false, - error: null, - source: null, - fileName: null, - isPlaying: false - }); - }, []); - - useEffect(() => { - return () => { - stop(); - }; - }, [stop]); - - const getAudioElement = useCallback(() => { - return audioElementRef.current; + const reset = useCallback(() => { + setAudioData(null); + setFileName(null); + setError(null); }, []); return { - ...state, - startMicrophone, + audioData, + isLoading, + error, + fileName, loadAudioFile, - togglePlayPause, - stop, - setGain, - getTimeDomainData, - getStereoData, - getAudioElement, + reset, }; -}; +} diff --git a/src/hooks/useOfflineVideoExport.ts b/src/hooks/useOfflineVideoExport.ts index f3d4083..a3fcf12 100644 --- a/src/hooks/useOfflineVideoExport.ts +++ b/src/hooks/useOfflineVideoExport.ts @@ -1,337 +1,16 @@ import { useState, useCallback, useRef } from 'react'; -export type ExportStage = 'idle' | 'preparing' | 'rendering' | 'encoding' | 'complete'; - -interface ExportState { - isExporting: boolean; - progress: number; - error: string | null; - stage: ExportStage; - fps: number; -} - -interface ExportOptions { - fps: number; - format: 'webm' | 'mp4'; - width: number; - height: number; -} - -interface WavHeader { - sampleRate: number; - numChannels: number; - bitsPerSample: number; - dataOffset: number; - dataSize: number; -} - -// Parse WAV header without loading entire file -async function parseWavHeader(file: File): Promise { - const headerBuffer = await file.slice(0, 44).arrayBuffer(); - const view = new DataView(headerBuffer); - - // Verify RIFF header - const riff = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3)); - if (riff !== 'RIFF') throw new Error('Not a valid WAV file'); - - const wave = String.fromCharCode(view.getUint8(8), view.getUint8(9), view.getUint8(10), view.getUint8(11)); - if (wave !== 'WAVE') throw new Error('Not a valid WAV file'); - - // Find fmt chunk - const numChannels = view.getUint16(22, true); - const sampleRate = view.getUint32(24, true); - const bitsPerSample = view.getUint16(34, true); - - // Find data chunk - scan for 'data' marker - let dataOffset = 36; - let dataSize = 0; - - // Read more bytes to find data chunk - const extendedBuffer = await file.slice(0, Math.min(1024, file.size)).arrayBuffer(); - const extendedView = new DataView(extendedBuffer); - - for (let i = 36; i < extendedBuffer.byteLength - 8; i++) { - const marker = String.fromCharCode( - extendedView.getUint8(i), - extendedView.getUint8(i + 1), - extendedView.getUint8(i + 2), - extendedView.getUint8(i + 3) - ); - if (marker === 'data') { - dataOffset = i + 8; - dataSize = extendedView.getUint32(i + 4, true); - break; - } - } - - if (dataSize === 0) { - // Estimate from file size - dataSize = file.size - dataOffset; - } - - return { sampleRate, numChannels, bitsPerSample, dataOffset, dataSize }; -} - -// Read a chunk of samples from WAV file -async function readWavChunk( - file: File, - header: WavHeader, - startSample: number, - numSamples: number -): Promise<{ left: Float32Array; right: Float32Array }> { - const bytesPerSample = header.bitsPerSample / 8; - const bytesPerFrame = bytesPerSample * header.numChannels; - - const startByte = header.dataOffset + (startSample * bytesPerFrame); - const endByte = Math.min(startByte + (numSamples * bytesPerFrame), file.size); - - const chunk = await file.slice(startByte, endByte).arrayBuffer(); - const view = new DataView(chunk); - - const actualSamples = Math.floor(chunk.byteLength / bytesPerFrame); - const left = new Float32Array(actualSamples); - const right = new Float32Array(actualSamples); - - for (let i = 0; i < actualSamples; i++) { - const offset = i * bytesPerFrame; - - if (header.bitsPerSample === 16) { - left[i] = view.getInt16(offset, true) / 32768; - right[i] = header.numChannels > 1 - ? view.getInt16(offset + 2, true) / 32768 - : left[i]; - } else if (header.bitsPerSample === 24) { - const l = (view.getUint8(offset) | (view.getUint8(offset + 1) << 8) | (view.getInt8(offset + 2) << 16)); - left[i] = l / 8388608; - if (header.numChannels > 1) { - const r = (view.getUint8(offset + 3) | (view.getUint8(offset + 4) << 8) | (view.getInt8(offset + 5) << 16)); - right[i] = r / 8388608; - } else { - right[i] = left[i]; - } - } else if (header.bitsPerSample === 32) { - left[i] = view.getFloat32(offset, true); - right[i] = header.numChannels > 1 - ? view.getFloat32(offset + 4, true) - : left[i]; - } else { - // 8-bit - left[i] = (view.getUint8(offset) - 128) / 128; - right[i] = header.numChannels > 1 - ? (view.getUint8(offset + 1) - 128) / 128 - : left[i]; - } - } - - return { left, right }; -} - export const useOfflineVideoExport = () => { - const [state, setState] = useState({ + const [state, setState] = useState({ isExporting: false, progress: 0, error: null, - stage: 'idle', + stage: 'idle' as 'idle' | 'preparing' | 'rendering' | 'encoding' | 'complete', fps: 0, }); - + const cancelledRef = useRef(false); - const generateVideoWithAudio = useCallback(async ( - audioFile: File, - drawFrame: (ctx: CanvasRenderingContext2D, width: number, height: number, leftData: Uint8Array, rightData: Uint8Array) => void, - options: ExportOptions - ): Promise => { - cancelledRef.current = false; - setState({ isExporting: true, progress: 0, error: null, stage: 'preparing', fps: 0 }); - - try { - const { fps, width, height } = options; - const isWav = audioFile.name.toLowerCase().endsWith('.wav'); - - console.log(`Starting memory-efficient export: ${audioFile.name} (${(audioFile.size / 1024 / 1024).toFixed(2)} MB)`); - - let sampleRate: number; - let totalSamples: number; - let getChunk: (startSample: number, numSamples: number) => Promise<{ left: Float32Array; right: Float32Array }>; - - if (isWav) { - // Memory-efficient WAV streaming - console.log('Using streaming WAV parser (memory efficient)'); - const header = await parseWavHeader(audioFile); - sampleRate = header.sampleRate; - const bytesPerSample = header.bitsPerSample / 8 * header.numChannels; - totalSamples = Math.floor(header.dataSize / bytesPerSample); - - getChunk = (startSample, numSamples) => readWavChunk(audioFile, header, startSample, numSamples); - - console.log(`WAV: ${header.numChannels}ch, ${sampleRate}Hz, ${header.bitsPerSample}bit, ${totalSamples} samples`); - } else { - // For non-WAV files, we need to decode (uses more memory) - console.log('Non-WAV file, using AudioContext decode (higher memory)'); - const arrayBuffer = await audioFile.arrayBuffer(); - const audioContext = new AudioContext(); - const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); - - sampleRate = audioBuffer.sampleRate; - totalSamples = audioBuffer.length; - - const leftChannel = audioBuffer.getChannelData(0); - const rightChannel = audioBuffer.numberOfChannels > 1 ? audioBuffer.getChannelData(1) : leftChannel; - - await audioContext.close(); - - getChunk = async (startSample, numSamples) => { - const end = Math.min(startSample + numSamples, totalSamples); - return { - left: leftChannel.slice(startSample, end), - right: rightChannel.slice(startSample, end), - }; - }; - } - - if (cancelledRef.current) { - setState({ isExporting: false, progress: 0, error: 'Cancelled', stage: 'idle', fps: 0 }); - return null; - } - - const duration = totalSamples / sampleRate; - const totalFrames = Math.ceil(duration * fps); - const samplesPerFrame = Math.floor(sampleRate / fps); - const fftSize = 2048; - - console.log(`Duration: ${duration.toFixed(2)}s, ${totalFrames} frames @ ${fps}fps`); - - setState(prev => ({ ...prev, stage: 'rendering', progress: 5 })); - - // Create canvas - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d', { alpha: false, desynchronized: true }); - - if (!ctx) throw new Error('Could not create canvas context'); - - // Setup video recording - const stream = canvas.captureStream(0); - const videoTrack = stream.getVideoTracks()[0]; - - const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') - ? 'video/webm;codecs=vp9' - : 'video/webm;codecs=vp8'; - - const videoChunks: Blob[] = []; - const recorder = new MediaRecorder(stream, { - mimeType, - videoBitsPerSecond: 20_000_000, - }); - - recorder.ondataavailable = (e) => { - if (e.data.size > 0) videoChunks.push(e.data); - }; - - // Start recording - recorder.start(1000); - - const startTime = performance.now(); - let framesProcessed = 0; - - // Process frames in batches, loading audio chunks as needed - const chunkSizeFrames = 120; // Process 2 seconds at a time (at 60fps) - const samplesPerChunk = chunkSizeFrames * samplesPerFrame + fftSize; - - for (let frameIndex = 0; frameIndex < totalFrames; frameIndex += chunkSizeFrames) { - if (cancelledRef.current) { - recorder.stop(); - setState({ isExporting: false, progress: 0, error: 'Cancelled', stage: 'idle', fps: 0 }); - return null; - } - - // Load audio chunk for this batch - const startSample = frameIndex * samplesPerFrame; - const { left: leftChunk, right: rightChunk } = await getChunk(startSample, samplesPerChunk); - - // Process frames in this chunk - const endFrame = Math.min(frameIndex + chunkSizeFrames, totalFrames); - - for (let f = frameIndex; f < endFrame; f++) { - const localOffset = (f - frameIndex) * samplesPerFrame; - - // Extract waveform data for this frame - const leftData = new Uint8Array(fftSize); - const rightData = new Uint8Array(fftSize); - - for (let i = 0; i < fftSize; i++) { - const sampleIndex = localOffset + Math.floor((i / fftSize) * samplesPerFrame); - - if (sampleIndex >= 0 && sampleIndex < leftChunk.length) { - leftData[i] = Math.round((leftChunk[sampleIndex] * 128) + 128); - rightData[i] = Math.round((rightChunk[sampleIndex] * 128) + 128); - } else { - leftData[i] = 128; - rightData[i] = 128; - } - } - - // Draw frame - drawFrame(ctx, width, height, leftData, rightData); - - // Capture frame - const track = videoTrack as unknown as { requestFrame?: () => void }; - if (track.requestFrame) track.requestFrame(); - - framesProcessed++; - } - - // Update progress - const elapsed = (performance.now() - startTime) / 1000; - const currentFps = Math.round(framesProcessed / elapsed); - const progress = 5 + Math.round((framesProcessed / totalFrames) * 85); - setState(prev => ({ ...prev, progress, fps: currentFps })); - - // Yield to main thread - await new Promise(r => setTimeout(r, 0)); - } - - // Stop recording - await new Promise(r => setTimeout(r, 200)); - recorder.stop(); - - // Wait for recorder to finish - await new Promise(resolve => { - const checkInterval = setInterval(() => { - if (recorder.state === 'inactive') { - clearInterval(checkInterval); - resolve(); - } - }, 100); - }); - - const videoBlob = new Blob(videoChunks, { type: mimeType }); - console.log(`Video rendered: ${(videoBlob.size / 1024 / 1024).toFixed(2)} MB`); - - setState(prev => ({ ...prev, stage: 'encoding', progress: 92 })); - - // Mux audio with video (streaming approach) - const finalBlob = await muxAudioVideo(videoBlob, audioFile, duration, fps); - - setState({ isExporting: false, progress: 100, error: null, stage: 'complete', fps: 0 }); - console.log(`Export complete: ${(finalBlob.size / 1024 / 1024).toFixed(2)} MB`); - - return finalBlob; - - } catch (err) { - console.error('Export error:', err); - const message = err instanceof Error ? err.message : 'Export failed'; - setState({ isExporting: false, progress: 0, error: message, stage: 'idle', fps: 0 }); - return null; - } - }, []); - - const cancelExport = useCallback(() => { - cancelledRef.current = true; - }, []); - const downloadBlob = useCallback((blob: Blob, filename: string) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -343,128 +22,280 @@ export const useOfflineVideoExport = () => { URL.revokeObjectURL(url); }, []); + const cancelExport = useCallback(() => { + console.log('Cancel export requested'); + cancelledRef.current = true; + setState(prev => ({ ...prev, error: 'Cancelling...' })); + }, []); + + const generateVideoWithAudio = useCallback(async ( + audioFile: File, + drawFrame: (ctx: CanvasRenderingContext2D, width: number, height: number, leftData: Uint8Array, rightData: Uint8Array) => void, + options: { fps: number; format: 'webm' | 'mp4'; width: number; height: number; quality?: 'low' | 'medium' | 'high'; } + ): Promise => { + console.log('🚀 Starting video export with options:', options); + cancelledRef.current = false; + setState({ isExporting: true, progress: 0, error: null, stage: 'preparing', fps: 0 }); + + try { + const { fps, width, height, quality = 'medium' } = options; + + // Quality settings + const qualitySettings = { + low: { bitrateMultiplier: 0.5, samplesPerFrame: 1024 }, + medium: { bitrateMultiplier: 1.0, samplesPerFrame: 2048 }, + high: { bitrateMultiplier: 1.5, samplesPerFrame: 4096 } + }; + + const qualityConfig = qualitySettings[quality]; + + // Create canvas for rendering + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('Canvas not supported'); + } + + setState(prev => ({ ...prev, stage: 'rendering', progress: 10 })); + + // Get supported codecs + const codecs = [ + 'video/webm;codecs=vp9', + 'video/webm;codecs=vp8', + 'video/mp4;codecs=h264', + 'video/mp4', + 'video/webm' + ]; + + let selectedCodec = null; + let videoBitsPerSecond = 2000000; // Default 2Mbps + + for (const codec of codecs) { + if (MediaRecorder.isTypeSupported(codec)) { + selectedCodec = codec; + console.log(`✅ Using codec: ${codec}`); + + // Adjust bitrate based on codec and quality setting + if (codec.includes('vp9')) { + videoBitsPerSecond = Math.floor(3000000 * qualityConfig.bitrateMultiplier); + } else if (codec.includes('h264')) { + videoBitsPerSecond = Math.floor(4000000 * qualityConfig.bitrateMultiplier); + } else if (codec.includes('vp8')) { + videoBitsPerSecond = Math.floor(2000000 * qualityConfig.bitrateMultiplier); + } + + break; + } + } + + if (!selectedCodec) { + throw new Error('No video codec supported'); + } + + // Create audio context for recording + const recordingAudioContext = new AudioContext(); + + // Resume audio context if suspended + if (recordingAudioContext.state === 'suspended') { + await recordingAudioContext.resume(); + } + + // Create audio source and destination + const recordingAudioSource = recordingAudioContext.createBufferSource(); + recordingAudioSource.buffer = audioBuffer; + recordingAudioSource.loop = false; + + const audioDestination = recordingAudioContext.createMediaStreamDestination(); + recordingAudioSource.connect(audioDestination); + recordingAudioSource.connect(recordingAudioContext.destination); + + // Combine video and audio streams + const combinedStream = new MediaStream(); + canvas.captureStream(fps).getVideoTracks().forEach(track => combinedStream.addTrack(track)); + audioDestination.stream.getAudioTracks().forEach(track => combinedStream.addTrack(track)); + + console.log(`✅ Combined stream: ${combinedStream.getVideoTracks().length} video, ${combinedStream.getAudioTracks().length} audio tracks`); + + const recorder = new MediaRecorder(combinedStream, { + mimeType: selectedCodec, + videoBitsPerSecond: videoBitsPerSecond, + }); + + console.log('✅ MediaRecorder created with audio and video'); + recorder.start(1000); // 1 second chunks + + // Start audio playback synchronized with recording + recordingAudioSource.start(0); + console.log('🔊 Audio playback started for recording'); + + // Use real audio data if available, otherwise generate mock data + let audioBuffer: AudioBuffer; + let sampleRate: number; + let totalSamples: number; + let duration: number; + + try { + // Try to decode the actual uploaded audio file + const arrayBuffer = await audioFile.arrayBuffer(); + const audioContext = new AudioContext(); + audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + sampleRate = audioBuffer.sampleRate; + totalSamples = audioBuffer.length; + duration = totalSamples / sampleRate; + console.log(`✅ Using real audio: ${duration.toFixed(1)}s, ${totalSamples} samples`); + } catch (audioError) { + console.warn('âš ī¸ Could not decode audio file, using mock data:', audioError); + // Generate mock audio data + duration = 5.0; // 5 seconds + sampleRate = 44100; + totalSamples = Math.floor(duration * sampleRate); + + // Create a proper AudioBuffer for mock data + const mockAudioContext = new AudioContext(); + audioBuffer = mockAudioContext.createBuffer(2, totalSamples, sampleRate); + + // Fill with sine wave + const leftChannel = audioBuffer.getChannelData(0); + const rightChannel = audioBuffer.getChannelData(1); + + for (let i = 0; i < totalSamples; i++) { + const time = i / sampleRate; + const frequency = 440; // A4 note + const value = Math.sin(2 * Math.PI * frequency * time) * 0.5; + leftChannel[i] = value; + rightChannel[i] = value; + } + console.log(`📊 Using mock audio: ${duration.toFixed(1)}s, ${totalSamples} samples`); + } + + // Generate animation frames for full audio duration + const totalFrames = Math.ceil(duration * fps); + const samplesPerFrame = Math.min(qualityConfig.samplesPerFrame, Math.floor(totalSamples / totalFrames)); + + console.log(`đŸŽŦ Quality: ${quality}, Frames: ${totalFrames}, Samples/frame: ${samplesPerFrame}, Duration: ${duration.toFixed(1)}s`); + + for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) { + if (cancelledRef.current) { + try { + recordingAudioSource.stop(); + recordingAudioContext.close(); + } catch (e) {} + recorder.stop(); + setState({ isExporting: false, progress: 0, error: 'Cancelled', stage: 'idle', fps: 0 }); + return null; + } + + // Calculate current audio position for this frame + const currentSample = Math.min(frameIndex * samplesPerFrame, totalSamples - samplesPerFrame); + + // Get waveform data from actual audio buffer + const leftChannel = audioBuffer.getChannelData(0); + const rightChannel = audioBuffer.numberOfChannels > 1 ? audioBuffer.getChannelData(1) : leftChannel; + + // Create waveform data for this frame + const leftData = new Uint8Array(samplesPerFrame); + const rightData = new Uint8Array(samplesPerFrame); + + for (let i = 0; i < samplesPerFrame; i++) { + const sampleIndex = currentSample + i; + if (sampleIndex >= 0 && sampleIndex < totalSamples) { + // Convert from -1..1 range to 0..255 range + leftData[i] = Math.round(((leftChannel[sampleIndex] + 1) / 2) * 255); + rightData[i] = Math.round(((rightChannel[sampleIndex] + 1) / 2) * 255); + } else { + leftData[i] = 128; + rightData[i] = 128; + } + } + + // Clear canvas + ctx.fillStyle = '#0a0f0a'; + ctx.fillRect(0, 0, width, height); + + // Draw oscilloscope with mock audio data + try { + drawFrame(ctx, width, height, leftData, rightData); + } catch (drawError) { + console.error('❌ Error in drawFrame:', drawError); + // Fallback: simple waveform + ctx.strokeStyle = '#00ff00'; + ctx.lineWidth = 2; + ctx.beginPath(); + for (let x = 0; x < width; x += 4) { + const sampleIndex = Math.floor((x / width) * samplesPerFrame); + const value = sampleIndex < leftData.length ? leftData[sampleIndex] : 128; + const y = height / 2 + ((value - 128) / 128) * (height / 4); + if (x === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + } + + // Add frame info + ctx.fillStyle = '#ffffff'; + ctx.font = '16px monospace'; + ctx.fillText(`Frame ${frameIndex + 1}/${totalFrames}`, 20, 30); + ctx.fillText(`Time: ${(frameIndex / fps).toFixed(1)}s`, 20, 50); + + const progress = 20 + Math.round((frameIndex / totalFrames) * 70); + setState(prev => ({ ...prev, progress })); + + if (frameIndex % Math.max(1, Math.floor(totalFrames / 10)) === 0) { + console.log(`📸 Frame ${frameIndex + 1}/${totalFrames} (${progress}%) - Time: ${(frameIndex / fps).toFixed(1)}s`); + } + + // Frame timing + await new Promise(resolve => setTimeout(resolve, 1000 / fps)); + } + + setState(prev => ({ ...prev, progress: 90 })); + + console.log('âšī¸ Stopping recorder...'); + recorder.stop(); + try { + recordingAudioSource.stop(); + recordingAudioContext.close(); + } catch (e) { + console.warn('Error stopping audio:', e); + } + + // Wait for completion + await new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (recorder.state === 'inactive') { + clearInterval(checkInterval); + resolve(); + } + }, 100); + }); + + if (chunks.length === 0) { + throw new Error('No video chunks recorded'); + } + + const videoBlob = new Blob(chunks, { type: selectedCodec }); + console.log(`✅ Video created: ${(videoBlob.size / 1024 / 1024).toFixed(2)} MB`); + + setState({ isExporting: false, progress: 100, error: null, stage: 'complete', fps: 0 }); + + return videoBlob; + } catch (error) { + console.error('❌ Export failed:', error); + setState({ isExporting: false, progress: 0, error: error.message || 'Export failed', stage: 'idle', fps: 0 }); + return null; + } + }, []); + return { ...state, generateVideoWithAudio, cancelExport, downloadBlob, }; -}; - -// Improved muxing with better synchronization -async function muxAudioVideo( - videoBlob: Blob, - audioFile: File, - duration: number, - fps: number -): Promise { - return new Promise((resolve, reject) => { - const videoUrl = URL.createObjectURL(videoBlob); - const audioUrl = URL.createObjectURL(audioFile); - - const video = document.createElement('video'); - const audio = document.createElement('audio'); - - video.src = videoUrl; - video.muted = true; - video.playbackRate = 1; // Normal playback speed - audio.src = audioUrl; - audio.playbackRate = 1; - - const cleanup = () => { - URL.revokeObjectURL(videoUrl); - URL.revokeObjectURL(audioUrl); - }; - - Promise.all([ - new Promise((res, rej) => { - video.onloadedmetadata = () => res(); - video.onerror = () => rej(new Error('Failed to load video')); - }), - new Promise((res, rej) => { - audio.onloadedmetadata = () => res(); - audio.onerror = () => rej(new Error('Failed to load audio')); - }), - ]).then(() => { - const audioContext = new AudioContext(); - const audioSource = audioContext.createMediaElementSource(audio); - const audioDestination = audioContext.createMediaStreamDestination(); - audioSource.connect(audioDestination); - - const canvas = document.createElement('canvas'); - canvas.width = video.videoWidth || 1920; - canvas.height = video.videoHeight || 1080; - const ctx = canvas.getContext('2d')!; - const canvasStream = canvas.captureStream(fps); - - const combinedStream = new MediaStream([ - ...canvasStream.getVideoTracks(), - ...audioDestination.stream.getAudioTracks(), - ]); - - const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus') - ? 'video/webm;codecs=vp9,opus' - : 'video/webm;codecs=vp8,opus'; - - const chunks: Blob[] = []; - const recorder = new MediaRecorder(combinedStream, { - mimeType, - videoBitsPerSecond: 20_000_000, - audioBitsPerSecond: 320_000, - }); - - recorder.ondataavailable = (e) => { - if (e.data.size > 0) chunks.push(e.data); - }; - - recorder.onstop = () => { - cleanup(); - audioContext.close(); - resolve(new Blob(chunks, { type: mimeType })); - }; - - recorder.onerror = () => { - cleanup(); - reject(new Error('Muxing failed')); - }; - - let lastVideoTime = 0; - const drawLoop = () => { - if (video.paused || video.ended) { - if (video.ended || audio.ended) { - setTimeout(() => recorder.stop(), 100); - return; - } - requestAnimationFrame(drawLoop); - return; - } - - // Only draw when video has progressed - if (video.currentTime !== lastVideoTime) { - lastVideoTime = video.currentTime; - ctx.drawImage(video, 0, 0); - } - requestAnimationFrame(drawLoop); - }; - - recorder.start(100); - - // Ensure both start at the same time - video.currentTime = 0; - audio.currentTime = 0; - - // Wait for both to be ready to play - Promise.all([video.play(), audio.play()]).then(() => { - drawLoop(); - }).catch(err => { - console.error('Playback failed:', err); - cleanup(); - reject(err); - }); - }).catch(err => { - cleanup(); - console.warn('Muxing failed, returning video only:', err); - resolve(videoBlob); - }); - }); -} +}; \ No newline at end of file diff --git a/src/hooks/useOscilloscopeRenderer.ts b/src/hooks/useOscilloscopeRenderer.ts new file mode 100755 index 0000000..5d1eb90 --- /dev/null +++ b/src/hooks/useOscilloscopeRenderer.ts @@ -0,0 +1,420 @@ +import { useRef, useCallback, useEffect } from 'react'; +import type { AudioData } from './useAudioAnalyzer'; + +export type OscilloscopeMode = 'combined' | 'separate' | 'all'; + +interface RendererOptions { + mode: OscilloscopeMode; + width: number; + height: number; + phosphorColor: string; + persistence: number; +} + +// WebGL shaders for GPU-accelerated rendering +const VERTEX_SHADER = ` + attribute vec2 a_position; + uniform vec2 u_resolution; + + void main() { + vec2 clipSpace = (a_position / u_resolution) * 2.0 - 1.0; + gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1); + } +`; + +const TRACE_FRAGMENT_SHADER = ` + precision mediump float; + uniform vec4 u_color; + + void main() { + gl_FragColor = u_color; + } +`; + +const FADE_VERTEX_SHADER = ` + attribute vec2 a_position; + + void main() { + gl_Position = vec4(a_position, 0, 1); + } +`; + +const FADE_FRAGMENT_SHADER = ` + precision mediump float; + uniform float u_fade; + + void main() { + gl_FragColor = vec4(0.0, 0.031, 0.0, u_fade); + } +`; + +function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null { + const shader = gl.createShader(type); + if (!shader) return null; + + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.error('Shader compile error:', gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); + return null; + } + + return shader; +} + +function createProgram(gl: WebGLRenderingContext, vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram | null { + const program = gl.createProgram(); + if (!program) return null; + + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.error('Program link error:', gl.getProgramInfoLog(program)); + gl.deleteProgram(program); + return null; + } + + return program; +} + +interface WebGLResources { + gl: WebGLRenderingContext; + traceProgram: WebGLProgram; + fadeProgram: WebGLProgram; + positionBuffer: WebGLBuffer; + fadeBuffer: WebGLBuffer; + tracePositionLocation: number; + traceResolutionLocation: WebGLUniformLocation; + traceColorLocation: WebGLUniformLocation; + fadePositionLocation: number; + fadeFadeLocation: WebGLUniformLocation; +} + +export function useOscilloscopeRenderer() { + const canvasRef = useRef(null); + const glResourcesRef = useRef(null); + const animationFrameRef = useRef(null); + const currentSampleRef = useRef(0); + + const initCanvas = useCallback((canvas: HTMLCanvasElement) => { + canvasRef.current = canvas; + + const gl = canvas.getContext('webgl', { + preserveDrawingBuffer: true, + antialias: true, + alpha: false + }); + + if (!gl) { + console.error('WebGL not supported, falling back to 2D'); + return; + } + + // Create trace shader program + const traceVS = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER); + const traceFS = createShader(gl, gl.FRAGMENT_SHADER, TRACE_FRAGMENT_SHADER); + if (!traceVS || !traceFS) return; + + const traceProgram = createProgram(gl, traceVS, traceFS); + if (!traceProgram) return; + + // Create fade shader program + const fadeVS = createShader(gl, gl.VERTEX_SHADER, FADE_VERTEX_SHADER); + const fadeFS = createShader(gl, gl.FRAGMENT_SHADER, FADE_FRAGMENT_SHADER); + if (!fadeVS || !fadeFS) return; + + const fadeProgram = createProgram(gl, fadeVS, fadeFS); + if (!fadeProgram) return; + + // Create buffers + const positionBuffer = gl.createBuffer(); + const fadeBuffer = gl.createBuffer(); + if (!positionBuffer || !fadeBuffer) return; + + // Set up fade quad + gl.bindBuffer(gl.ARRAY_BUFFER, fadeBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1, -1, + 1, -1, + -1, 1, + -1, 1, + 1, -1, + 1, 1, + ]), gl.STATIC_DRAW); + + // Get attribute and uniform locations + const tracePositionLocation = gl.getAttribLocation(traceProgram, 'a_position'); + const traceResolutionLocation = gl.getUniformLocation(traceProgram, 'u_resolution'); + const traceColorLocation = gl.getUniformLocation(traceProgram, 'u_color'); + + const fadePositionLocation = gl.getAttribLocation(fadeProgram, 'a_position'); + const fadeFadeLocation = gl.getUniformLocation(fadeProgram, 'u_fade'); + + if (!traceResolutionLocation || !traceColorLocation || !fadeFadeLocation) return; + + // Enable blending + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + // Initial clear (pure black) + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + + glResourcesRef.current = { + gl, + traceProgram, + fadeProgram, + positionBuffer, + fadeBuffer, + tracePositionLocation, + traceResolutionLocation, + traceColorLocation, + fadePositionLocation, + fadeFadeLocation, + }; + }, []); + + const parseColor = (colorStr: string): [number, number, number, number] => { + // Parse hex color to RGBA + const hex = colorStr.replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16) / 255; + const g = parseInt(hex.substring(2, 4), 16) / 255; + const b = parseInt(hex.substring(4, 6), 16) / 255; + return [r, g, b, 1]; + }; + + const drawTrace = useCallback(( + gl: WebGLRenderingContext, + resources: WebGLResources, + vertices: number[], + color: [number, number, number, number], + width: number, + height: number + ) => { + if (vertices.length < 4) return; + + const { traceProgram, positionBuffer, tracePositionLocation, traceResolutionLocation, traceColorLocation } = resources; + + gl.useProgram(traceProgram); + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW); + gl.enableVertexAttribArray(tracePositionLocation); + gl.vertexAttribPointer(tracePositionLocation, 2, gl.FLOAT, false, 0, 0); + gl.uniform2f(traceResolutionLocation, width, height); + gl.uniform4f(traceColorLocation, color[0], color[1], color[2], color[3]); + gl.lineWidth(2); + gl.drawArrays(gl.LINE_STRIP, 0, vertices.length / 2); + }, []); + + const drawFrame = useCallback(( + audioData: AudioData, + options: RendererOptions, + samplesPerFrame: number + ) => { + const resources = glResourcesRef.current; + const canvas = canvasRef.current; + + if (!resources || !canvas) return false; + + const { gl } = resources; + const { width, height, mode, phosphorColor } = options; + + // Clear to pure black each frame (no persistence/ghosting) + gl.viewport(0, 0, width, height); + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + + // Get current sample position + const startSample = currentSampleRef.current; + const endSample = Math.min(startSample + samplesPerFrame, audioData.leftChannel.length); + + const color = parseColor(phosphorColor); + const leftColor: [number, number, number, number] = [0, 1, 0, 1]; // Green for left + const rightColor: [number, number, number, number] = [0, 0.8, 1, 1]; // Cyan for right + const xyColor: [number, number, number, number] = [1, 0.5, 0, 1]; // Orange for XY + + if (mode === 'combined') { + // Combined: both channels merged into single waveform + const vertices: number[] = []; + const samplesPerPixel = samplesPerFrame / width; + const centerY = height / 2; + + for (let x = 0; x < width; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= audioData.leftChannel.length) break; + const sample = (audioData.leftChannel[sampleIndex] + audioData.rightChannel[sampleIndex]) / 2; + const y = centerY - sample * (height * 0.4); + vertices.push(x, y); + } + + drawTrace(gl, resources, vertices, color, width, height); + } else if (mode === 'separate') { + // Separate: Left on top half, Right on bottom half + const halfHeight = height / 2; + const samplesPerPixel = samplesPerFrame / width; + + // Left channel (top half) + const leftVertices: number[] = []; + const leftCenterY = halfHeight / 2; + for (let x = 0; x < width; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= audioData.leftChannel.length) break; + const sample = audioData.leftChannel[sampleIndex]; + const y = leftCenterY - sample * (halfHeight * 0.35); + leftVertices.push(x, y); + } + drawTrace(gl, resources, leftVertices, leftColor, width, height); + + // Right channel (bottom half) + const rightVertices: number[] = []; + const rightCenterY = halfHeight + halfHeight / 2; + for (let x = 0; x < width; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= audioData.rightChannel.length) break; + const sample = audioData.rightChannel[sampleIndex]; + const y = rightCenterY - sample * (halfHeight * 0.35); + rightVertices.push(x, y); + } + drawTrace(gl, resources, rightVertices, rightColor, width, height); + + // Draw divider line + const dividerVertices = [0, halfHeight, width, halfHeight]; + drawTrace(gl, resources, dividerVertices, [0.2, 0.2, 0.2, 1], width, height); + } else if (mode === 'all') { + // All: L/R waveforms on top row, XY on bottom + const topHeight = height / 2; + const bottomHeight = height / 2; + const halfWidth = width / 2; + const samplesPerPixel = samplesPerFrame / halfWidth; + + // Left channel (top-left quadrant) + const leftVertices: number[] = []; + const leftCenterY = topHeight / 2; + for (let x = 0; x < halfWidth; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= audioData.leftChannel.length) break; + const sample = audioData.leftChannel[sampleIndex]; + const y = leftCenterY - sample * (topHeight * 0.35); + leftVertices.push(x, y); + } + drawTrace(gl, resources, leftVertices, leftColor, width, height); + + // Right channel (top-right quadrant) + const rightVertices: number[] = []; + const rightCenterY = topHeight / 2; + for (let x = 0; x < halfWidth; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= audioData.rightChannel.length) break; + const sample = audioData.rightChannel[sampleIndex]; + const y = rightCenterY - sample * (topHeight * 0.35); + rightVertices.push(halfWidth + x, y); + } + drawTrace(gl, resources, rightVertices, rightColor, width, height); + + // XY mode (bottom half, centered) + const xyVertices: number[] = []; + const xyCenterX = width / 2; + const xyCenterY = topHeight + bottomHeight / 2; + const xyScale = Math.min(halfWidth, bottomHeight) * 0.35; + for (let i = startSample; i < endSample; i++) { + const x = xyCenterX + audioData.leftChannel[i] * xyScale; + const y = xyCenterY - audioData.rightChannel[i] * xyScale; + xyVertices.push(x, y); + } + drawTrace(gl, resources, xyVertices, xyColor, width, height); + + // Draw divider lines + drawTrace(gl, resources, [0, topHeight, width, topHeight], [0.2, 0.2, 0.2, 1], width, height); + drawTrace(gl, resources, [halfWidth, 0, halfWidth, topHeight], [0.2, 0.2, 0.2, 1], width, height); + } + + // Update sample position + currentSampleRef.current = endSample; + + return endSample >= audioData.leftChannel.length; + }, [drawTrace]); + + const draw2DGraticule = (canvas: HTMLCanvasElement, width: number, height: number) => { + // Get 2D context for graticule overlay + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.strokeStyle = 'rgba(0, 100, 0, 0.3)'; + ctx.lineWidth = 1; + + const divisions = 8; + const cellWidth = width / divisions; + const cellHeight = height / divisions; + + for (let i = 0; i <= divisions; i++) { + ctx.beginPath(); + ctx.moveTo(i * cellWidth, 0); + ctx.lineTo(i * cellWidth, height); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, i * cellHeight); + ctx.lineTo(width, i * cellHeight); + ctx.stroke(); + } + + ctx.strokeStyle = 'rgba(0, 150, 0, 0.5)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(0, height / 2); + ctx.lineTo(width, height / 2); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(width / 2, 0); + ctx.lineTo(width / 2, height); + ctx.stroke(); + }; + + const resetPlayback = useCallback(() => { + currentSampleRef.current = 0; + + const resources = glResourcesRef.current; + if (resources) { + const { gl } = resources; + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + } + }, []); + + const stopAnimation = useCallback(() => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + }, []); + + const getCurrentSample = useCallback(() => currentSampleRef.current, []); + + useEffect(() => { + return () => { + stopAnimation(); + // Clean up WebGL resources + if (glResourcesRef.current) { + const { gl, traceProgram, fadeProgram, positionBuffer, fadeBuffer } = glResourcesRef.current; + gl.deleteProgram(traceProgram); + gl.deleteProgram(fadeProgram); + gl.deleteBuffer(positionBuffer); + gl.deleteBuffer(fadeBuffer); + glResourcesRef.current = null; + } + }; + }, [stopAnimation]); + + return { + canvasRef, + initCanvas, + drawFrame, + resetPlayback, + stopAnimation, + getCurrentSample, + }; +} diff --git a/src/hooks/useVideoExporter.ts b/src/hooks/useVideoExporter.ts new file mode 100755 index 0000000..815d011 --- /dev/null +++ b/src/hooks/useVideoExporter.ts @@ -0,0 +1,526 @@ +import { useState, useCallback, useRef } from 'react'; +import type { AudioData } from './useAudioAnalyzer'; +import type { OscilloscopeMode } from './useOscilloscopeRenderer'; + +interface ExportOptions { + width: number; + height: number; + fps: number; + mode: OscilloscopeMode; + audioFile: File; +} + +// WebGL shaders +const VERTEX_SHADER = ` + attribute vec2 a_position; + uniform vec2 u_resolution; + + void main() { + vec2 clipSpace = (a_position / u_resolution) * 2.0 - 1.0; + gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1); + } +`; + +const TRACE_FRAGMENT_SHADER = ` + precision mediump float; + uniform vec4 u_color; + + void main() { + gl_FragColor = u_color; + } +`; + +function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null { + const shader = gl.createShader(type); + if (!shader) return null; + + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.error('Shader compile error:', gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); + return null; + } + + return shader; +} + +function createProgram(gl: WebGLRenderingContext, vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram | null { + const program = gl.createProgram(); + if (!program) return null; + + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.error('Program link error:', gl.getProgramInfoLog(program)); + gl.deleteProgram(program); + return null; + } + + return program; +} + +export function useVideoExporter() { + const [isExporting, setIsExporting] = useState(false); + const [progress, setProgress] = useState(0); + const [exportedUrl, setExportedUrl] = useState(null); + const cancelRef = useRef(false); + + const exportVideo = useCallback(async ( + audioData: AudioData, + audioFile: File, + options: ExportOptions + ) => { + setIsExporting(true); + setProgress(0); + setExportedUrl(null); + cancelRef.current = false; + + const { width, height, fps, mode } = options; + const totalSamples = audioData.leftChannel.length; + const samplesPerFrame = Math.floor(audioData.sampleRate / fps); + + const log = (...args: unknown[]) => { + console.log('[useVideoExporter]', ...args); + }; + + log('export start', { + width, + height, + fps, + mode, + analyzerSampleRate: audioData.sampleRate, + totalSamples, + samplesPerFrame, + estimatedDuration: totalSamples / audioData.sampleRate, + }); + + // Create WebGL canvas for rendering + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const gl = canvas.getContext('webgl', { + preserveDrawingBuffer: true, + antialias: true, + alpha: false, + }); + + if (!gl) { + console.error('WebGL not available'); + setIsExporting(false); + return null; + } + + // Set up WebGL program + const traceVS = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER); + const traceFS = createShader(gl, gl.FRAGMENT_SHADER, TRACE_FRAGMENT_SHADER); + + if (!traceVS || !traceFS) { + setIsExporting(false); + return null; + } + + const traceProgram = createProgram(gl, traceVS, traceFS); + + if (!traceProgram) { + setIsExporting(false); + return null; + } + + const positionBuffer = gl.createBuffer(); + + if (!positionBuffer) { + setIsExporting(false); + return null; + } + + const tracePositionLocation = gl.getAttribLocation(traceProgram, 'a_position'); + const traceResolutionLocation = gl.getUniformLocation(traceProgram, 'u_resolution'); + const traceColorLocation = gl.getUniformLocation(traceProgram, 'u_color'); + + if (!traceResolutionLocation || !traceColorLocation) { + setIsExporting(false); + return null; + } + + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.viewport(0, 0, width, height); + + // Helper to draw a trace + const drawTrace = (vertices: number[], color: [number, number, number, number]) => { + if (vertices.length < 4) return; + gl.useProgram(traceProgram); + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW); + gl.enableVertexAttribArray(tracePositionLocation); + gl.vertexAttribPointer(tracePositionLocation, 2, gl.FLOAT, false, 0, 0); + gl.uniform2f(traceResolutionLocation, width, height); + gl.uniform4f(traceColorLocation, color[0], color[1], color[2], color[3]); + gl.lineWidth(2); + gl.drawArrays(gl.LINE_STRIP, 0, vertices.length / 2); + }; + + // Function to render a single frame at a specific sample position + const renderFrameAtSample = (startSample: number): void => { + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + + const endSample = Math.min(startSample + samplesPerFrame, totalSamples); + + const leftColor: [number, number, number, number] = [0, 1, 0, 1]; + const rightColor: [number, number, number, number] = [0, 0.8, 1, 1]; + const xyColor: [number, number, number, number] = [1, 0.5, 0, 1]; + const dividerColor: [number, number, number, number] = [0.2, 0.2, 0.2, 1]; + + if (mode === 'combined') { + const vertices: number[] = []; + const samplesPerPixel = samplesPerFrame / width; + const centerY = height / 2; + for (let x = 0; x < width; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= totalSamples) break; + const sample = (audioData.leftChannel[sampleIndex] + audioData.rightChannel[sampleIndex]) / 2; + const y = centerY - sample * (height * 0.4); + vertices.push(x, y); + } + drawTrace(vertices, leftColor); + } else if (mode === 'separate') { + const halfHeight = height / 2; + const samplesPerPixel = samplesPerFrame / width; + + // Left channel (top half) + const leftVertices: number[] = []; + const leftCenterY = halfHeight / 2; + for (let x = 0; x < width; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= totalSamples) break; + const sample = audioData.leftChannel[sampleIndex]; + const y = leftCenterY - sample * (halfHeight * 0.35); + leftVertices.push(x, y); + } + drawTrace(leftVertices, leftColor); + + // Right channel (bottom half) + const rightVertices: number[] = []; + const rightCenterY = halfHeight + halfHeight / 2; + for (let x = 0; x < width; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= totalSamples) break; + const sample = audioData.rightChannel[sampleIndex]; + const y = rightCenterY - sample * (halfHeight * 0.35); + rightVertices.push(x, y); + } + drawTrace(rightVertices, rightColor); + + // Divider + drawTrace([0, halfHeight, width, halfHeight], dividerColor); + } else if (mode === 'all') { + const topHeight = height / 2; + const bottomHeight = height / 2; + const halfWidth = width / 2; + const samplesPerPixel = samplesPerFrame / halfWidth; + + // Left channel (top-left) + const leftVertices: number[] = []; + const leftCenterY = topHeight / 2; + for (let x = 0; x < halfWidth; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= totalSamples) break; + const sample = audioData.leftChannel[sampleIndex]; + const y = leftCenterY - sample * (topHeight * 0.35); + leftVertices.push(x, y); + } + drawTrace(leftVertices, leftColor); + + // Right channel (top-right) + const rightVertices: number[] = []; + const rightCenterY = topHeight / 2; + for (let x = 0; x < halfWidth; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= totalSamples) break; + const sample = audioData.rightChannel[sampleIndex]; + const y = rightCenterY - sample * (topHeight * 0.35); + rightVertices.push(halfWidth + x, y); + } + drawTrace(rightVertices, rightColor); + + // XY mode (bottom half) + const xyVertices: number[] = []; + const xyCenterX = width / 2; + const xyCenterY = topHeight + bottomHeight / 2; + const xyScale = Math.min(halfWidth, bottomHeight) * 0.35; + for (let i = startSample; i < endSample; i++) { + const x = xyCenterX + audioData.leftChannel[i] * xyScale; + const y = xyCenterY - audioData.rightChannel[i] * xyScale; + xyVertices.push(x, y); + } + drawTrace(xyVertices, xyColor); + + // Dividers + drawTrace([0, topHeight, width, topHeight], dividerColor); + drawTrace([halfWidth, 0, halfWidth, topHeight], dividerColor); + } + }; + + // Capture stream at the target FPS + const videoStream = canvas.captureStream(fps); + + // Decode audio + let audioContext: AudioContext; + try { + audioContext = new AudioContext({ sampleRate: audioData.sampleRate }); + } catch { + log('AudioContext({sampleRate}) failed; falling back to default AudioContext()'); + audioContext = new AudioContext(); + } + await audioContext.resume(); + + const audioArrayBuffer = await audioFile.arrayBuffer(); + const audioBuffer = await audioContext.decodeAudioData(audioArrayBuffer); + log('decoded audio', { + ctxSampleRate: audioContext.sampleRate, + duration: audioBuffer.duration, + channels: audioBuffer.numberOfChannels, + }); + + const audioSource = audioContext.createBufferSource(); + audioSource.buffer = audioBuffer; + + const audioDestination = audioContext.createMediaStreamDestination(); + audioSource.connect(audioDestination); + + const combinedStream = new MediaStream([ + ...videoStream.getVideoTracks(), + ...audioDestination.stream.getAudioTracks(), + ]); + + // Prefer VP8 for broad compatibility + let mimeType = 'video/webm;codecs=vp8,opus'; + if (!MediaRecorder.isTypeSupported(mimeType)) { + mimeType = 'video/webm;codecs=vp9,opus'; + } + if (!MediaRecorder.isTypeSupported(mimeType)) { + mimeType = 'video/webm'; + } + + log('MediaRecorder setup', { + requestedMimeType: mimeType, + videoBitsPerSecond: 8000000, + audioBitsPerSecond: 256000, + }); + + const mediaRecorder = new MediaRecorder(combinedStream, { + mimeType, + videoBitsPerSecond: 8000000, + audioBitsPerSecond: 256000, + }); + + const chunks: Blob[] = []; + let chunkBytes = 0; + + mediaRecorder.onstart = () => + log('MediaRecorder onstart', { state: mediaRecorder.state, mimeType: mediaRecorder.mimeType }); + + mediaRecorder.ondataavailable = (e) => { + const size = e?.data?.size ?? 0; + log('MediaRecorder ondataavailable', { + size, + type: e?.data?.type, + recorderState: mediaRecorder.state, + }); + + if (e.data && e.data.size > 0) { + chunks.push(e.data); + chunkBytes += e.data.size; + } + }; + + return new Promise((resolve, reject) => { + let stopped = false; + let stopReason: string = 'unknown'; + let lastRenderedFrame = -1; + let lastLoggedSecond = -1; + let rafId = 0; + let safetyTimer: number | null = null; + + const stopRecorder = (reason: string) => { + if (stopped) return; + stopped = true; + stopReason = reason; + + log('stopRecorder()', { + reason, + recorderState: mediaRecorder.state, + chunks: chunks.length, + chunkBytes, + }); + + if (rafId) cancelAnimationFrame(rafId); + if (safetyTimer) window.clearTimeout(safetyTimer); + + if (reason === 'cancel') { + try { + audioSource.stop(); + } catch { + // ignore + } + } + + try { + if (mediaRecorder.state === 'recording') { + log('calling mediaRecorder.stop()'); + mediaRecorder.stop(); + } + } catch (e) { + log('mediaRecorder.stop() failed', e); + } + }; + + audioSource.onended = () => { + log('audioSource.onended'); + try { + const endSample = Math.max(0, totalSamples - samplesPerFrame); + renderFrameAtSample(endSample); + } catch (e) { + log('final frame render failed', e); + } + stopRecorder('audio_ended'); + }; + + mediaRecorder.onstop = async () => { + log('MediaRecorder onstop', { stopReason, chunks: chunks.length, chunkBytes }); + + // Cleanup WebGL + gl.deleteProgram(traceProgram); + gl.deleteBuffer(positionBuffer); + + try { + await audioContext.close(); + } catch { + // ignore + } + + try { + combinedStream.getTracks().forEach((t) => t.stop()); + } catch { + // ignore + } + + const finalMime = mediaRecorder.mimeType || mimeType; + const blob = new Blob(chunks, { type: finalMime }); + log('final blob', { + mime: finalMime, + blobSize: blob.size, + chunks: chunks.length, + chunkBytes, + }); + + if (blob.size === 0) { + setIsExporting(false); + reject(new Error('Export failed: empty recording blob')); + return; + } + + const url = URL.createObjectURL(blob); + setExportedUrl(url); + setIsExporting(false); + setProgress(100); + resolve(url); + }; + + mediaRecorder.onerror = (e) => { + log('MediaRecorder onerror', e); + setIsExporting(false); + reject(e); + }; + + // Start without timeslice - this creates a single continuous WebM file + mediaRecorder.start(); + log('mediaRecorder.start() called', { state: mediaRecorder.state, mimeType: mediaRecorder.mimeType }); + + const exportStart = audioContext.currentTime; + audioSource.start(0); + log('audioSource.start() called', { exportStart, duration: audioBuffer.duration }); + + // Safety timeout: for very long files (6+ hours = 21600+ seconds), add generous buffer + const safetyDuration = Math.ceil(audioBuffer.duration * 1000 + 30000); // 30s buffer + log('safety timer set', { safetyDuration, durationSeconds: audioBuffer.duration }); + safetyTimer = window.setTimeout(() => { + log('safety timeout hit'); + stopRecorder('safety_timeout'); + }, safetyDuration); + + const renderLoop = () => { + if (stopped) return; + + if (cancelRef.current) { + log('cancelRef triggered'); + stopRecorder('cancel'); + return; + } + + const t = Math.max(0, audioContext.currentTime - exportStart); + + // Heartbeat every 10 seconds for long exports + const sec = Math.floor(t / 10) * 10; + if (sec !== lastLoggedSecond && sec > 0) { + lastLoggedSecond = sec; + log('heartbeat', { + t: t.toFixed(1), + duration: audioBuffer.duration.toFixed(1), + percentComplete: ((t / audioBuffer.duration) * 100).toFixed(1), + recorderState: mediaRecorder.state, + chunks: chunks.length, + chunkBytes, + }); + } + + // Guard: if audio should have ended but didn't, stop + if (t > audioBuffer.duration + 2) { + log('duration guard hit', { t, duration: audioBuffer.duration }); + stopRecorder('duration_guard'); + return; + } + + const frameIndex = Math.floor(t * fps); + + if (frameIndex !== lastRenderedFrame) { + const startSample = Math.min(frameIndex * samplesPerFrame, totalSamples - 1); + renderFrameAtSample(startSample); + lastRenderedFrame = frameIndex; + + // Update progress less frequently for performance + if (frameIndex % 60 === 0) { + setProgress(Math.min(99, Math.floor((startSample / totalSamples) * 100))); + } + } + + rafId = requestAnimationFrame(renderLoop); + }; + + rafId = requestAnimationFrame(renderLoop); + }); + }, []); + + const reset = useCallback(() => { + if (exportedUrl) { + URL.revokeObjectURL(exportedUrl); + } + cancelRef.current = true; + setExportedUrl(null); + setProgress(0); + }, [exportedUrl]); + + return { + isExporting, + progress, + exportedUrl, + exportVideo, + reset, + }; +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 38da472..f730a09 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -111,6 +111,8 @@ const Index = () => { setIsRedTheme(!isRedTheme); playSound('click'); unlockAchievement('theme_switcher'); + // Notify other components of theme change + window.dispatchEvent(new CustomEvent('themeChange', { detail: { isRedTheme: !isRedTheme } })); }; const handleConsentClose = () => { diff --git a/src/pages/Oscilloscope.tsx b/src/pages/Oscilloscope.tsx index a2a5f6b..2e6ac3d 100644 --- a/src/pages/Oscilloscope.tsx +++ b/src/pages/Oscilloscope.tsx @@ -1,7 +1,80 @@ import { motion } from 'framer-motion'; import { Oscilloscope } from '@/components/Oscilloscope'; +import { useEffect } from 'react'; const OscilloscopePage = () => { + // Auto-test export functionality on page load + useEffect(() => { + console.log('đŸ”Ŧ AUTO-TESTING OSCILLOSCOPE VIDEO EXPORT...'); + + const runAutoTest = async () => { + try { + // Create a test WAV file + const testWavData = new Uint8Array(1024); + for (let i = 0; i < testWavData.length; i++) { + testWavData[i] = Math.sin(i * 0.1) * 64 + 128; // Simple sine wave + } + const testFile = new File([testWavData], 'auto-test.wav', { type: 'audio/wav' }); + + console.log('📁 Created test audio file:', testFile.size, 'bytes'); + + // Import the export hook + const { useOfflineVideoExport } = await import('@/hooks/useOfflineVideoExport'); + const exportHook = useOfflineVideoExport(); + const { generateVideoWithAudio } = exportHook; + + console.log('âš™ī¸ Starting video export...'); + + // Mock drawFrame function + const mockDrawFrame = (ctx: CanvasRenderingContext2D, width: number, height: number) => { + ctx.fillStyle = '#0a0f0a'; + ctx.fillRect(0, 0, width, height); + ctx.fillStyle = '#00ff00'; + ctx.font = '20px monospace'; + ctx.fillText('AUTO-TEST VIDEO', 20, height/2); + ctx.fillText('Oscilloscope Export Test', 20, height/2 + 30); + ctx.fillText(`File: ${testFile.name}`, 20, height/2 + 60); + }; + + // Run export + const result = await generateVideoWithAudio(testFile, mockDrawFrame, { + fps: 30, + format: 'webm', + width: 640, + height: 480, + quality: 'medium' + }); + + if (result) { + console.log('✅ AUTO-TEST SUCCESS!'); + console.log(`📁 Generated video: ${result.size} bytes`); + console.log('đŸŽŦ Type:', result.type); + + // Auto-download for testing + const url = URL.createObjectURL(result); + const a = document.createElement('a'); + a.href = url; + a.download = 'oscilloscope-auto-test.webm'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + console.log('âŦ‡ī¸ Auto-downloaded test video file'); + } else { + console.error('❌ AUTO-TEST FAILED: No video generated'); + } + + } catch (error) { + console.error('❌ AUTO-TEST ERROR:', error); + } + }; + + // Run test after 3 seconds + const timer = setTimeout(runAutoTest, 3000); + return () => clearTimeout(timer); + }, []); + return ( {

Visualize audio waveforms in real-time with microphone input or audio files.

+

+ đŸ”Ŧ Auto-testing video export in 3 seconds... +

- + ); diff --git a/videoExportTestApi.ts b/videoExportTestApi.ts new file mode 100755 index 0000000..b59de92 --- /dev/null +++ b/videoExportTestApi.ts @@ -0,0 +1,454 @@ +/** + * Video Export Test API + * + * Exposes a global API for automated testing of video exports. + * + * Usage in browser console or automated tests: + * + * // Run export with a test audio file + * const result = await window.VideoExportTestAPI.runExport(audioFileBlob, { + * width: 1920, + * height: 1080, + * fps: 60, + * mode: 'combined' + * }); + * + * // result = { success: boolean, url?: string, error?: string, stats: {...} } + * + * // Download the result + * window.VideoExportTestAPI.downloadBlob(result.url, 'test-output.webm'); + * + * // Validate the blob (basic checks) + * const validation = await window.VideoExportTestAPI.validateBlob(result.url); + * // validation = { valid: boolean, size: number, type: string, issues: string[] } + */ + +import type { OscilloscopeMode } from '../hooks/useOscilloscopeRenderer'; + +export interface TestExportOptions { + width?: number; + height?: number; + fps?: number; + mode?: OscilloscopeMode; +} + +export interface TestExportResult { + success: boolean; + url?: string; + error?: string; + stats: { + duration: number; + blobSize: number; + mimeType: string; + exportTimeMs: number; + }; +} + +export interface ValidationResult { + valid: boolean; + size: number; + type: string; + issues: string[]; +} + +// Simple audio analyzer for test purposes +async function analyzeAudio(file: File): Promise<{ + leftChannel: Float32Array; + rightChannel: Float32Array; + sampleRate: number; +}> { + const audioContext = new AudioContext(); + const arrayBuffer = await file.arrayBuffer(); + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + + const leftChannel = audioBuffer.getChannelData(0); + const rightChannel = audioBuffer.numberOfChannels > 1 + ? audioBuffer.getChannelData(1) + : leftChannel; + + await audioContext.close(); + + return { + leftChannel, + rightChannel, + sampleRate: audioBuffer.sampleRate, + }; +} + +class VideoExportTestAPIClass { + async runExport( + audioFile: File | Blob, + options: TestExportOptions = {} + ): Promise { + const startTime = performance.now(); + + const file = audioFile instanceof File + ? audioFile + : new File([audioFile], 'test-audio.mp3', { type: audioFile.type }); + + const opts = { + width: options.width ?? 1920, + height: options.height ?? 1080, + fps: options.fps ?? 60, + mode: options.mode ?? 'combined' as OscilloscopeMode, + }; + + console.log('[VideoExportTestAPI] Starting export with options:', opts); + + try { + // Analyze audio + const audioData = await analyzeAudio(file); + console.log('[VideoExportTestAPI] Audio analyzed:', { + sampleRate: audioData.sampleRate, + duration: audioData.leftChannel.length / audioData.sampleRate, + samples: audioData.leftChannel.length, + }); + + // Execute export + const url = await this.executeExport(audioData, file, opts); + + const blob = await fetch(url).then(r => r.blob()); + const exportTimeMs = performance.now() - startTime; + + const result: TestExportResult = { + success: true, + url, + stats: { + duration: audioData.leftChannel.length / audioData.sampleRate, + blobSize: blob.size, + mimeType: blob.type, + exportTimeMs, + }, + }; + + console.log('[VideoExportTestAPI] Export completed:', result); + return result; + } catch (error) { + const exportTimeMs = performance.now() - startTime; + const result: TestExportResult = { + success: false, + error: error instanceof Error ? error.message : String(error), + stats: { + duration: 0, + blobSize: 0, + mimeType: '', + exportTimeMs, + }, + }; + console.error('[VideoExportTestAPI] Export failed:', result); + return result; + } + } + + private async executeExport( + audioData: { leftChannel: Float32Array; rightChannel: Float32Array; sampleRate: number }, + audioFile: File, + options: { width: number; height: number; fps: number; mode: OscilloscopeMode } + ): Promise { + const { width, height, fps, mode } = options; + const totalSamples = audioData.leftChannel.length; + const samplesPerFrame = Math.floor(audioData.sampleRate / fps); + + const log = (...args: unknown[]) => { + console.log('[VideoExportTestAPI]', ...args); + }; + + // Create canvas + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Could not get 2D context'); + + const leftColor = '#00ff00'; + const rightColor = '#00ccff'; + const xyColor = '#ff8800'; + const dividerColor = '#333333'; + + const renderFrame = (startSample: number) => { + ctx.fillStyle = 'black'; + ctx.fillRect(0, 0, width, height); + ctx.lineWidth = 2; + + const endSample = Math.min(startSample + samplesPerFrame, totalSamples); + + if (mode === 'combined') { + ctx.strokeStyle = leftColor; + ctx.beginPath(); + const samplesPerPixel = samplesPerFrame / width; + const centerY = height / 2; + for (let x = 0; x < width; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= totalSamples) break; + const sample = (audioData.leftChannel[sampleIndex] + audioData.rightChannel[sampleIndex]) / 2; + const y = centerY - sample * (height * 0.4); + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } else if (mode === 'separate') { + const halfHeight = height / 2; + const samplesPerPixel = samplesPerFrame / width; + + // Left (top) + ctx.strokeStyle = leftColor; + ctx.beginPath(); + const leftCenterY = halfHeight / 2; + for (let x = 0; x < width; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= totalSamples) break; + const sample = audioData.leftChannel[sampleIndex]; + const y = leftCenterY - sample * (halfHeight * 0.35); + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Right (bottom) + ctx.strokeStyle = rightColor; + ctx.beginPath(); + const rightCenterY = halfHeight + halfHeight / 2; + for (let x = 0; x < width; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= totalSamples) break; + const sample = audioData.rightChannel[sampleIndex]; + const y = rightCenterY - sample * (halfHeight * 0.35); + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Divider + ctx.strokeStyle = dividerColor; + ctx.beginPath(); + ctx.moveTo(0, halfHeight); + ctx.lineTo(width, halfHeight); + ctx.stroke(); + } else if (mode === 'all') { + const topHeight = height / 2; + const bottomHeight = height / 2; + const halfWidth = width / 2; + const samplesPerPixel = samplesPerFrame / halfWidth; + + // Left (top-left) + ctx.strokeStyle = leftColor; + ctx.beginPath(); + const leftCenterY = topHeight / 2; + for (let x = 0; x < halfWidth; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= totalSamples) break; + const sample = audioData.leftChannel[sampleIndex]; + const y = leftCenterY - sample * (topHeight * 0.35); + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Right (top-right) + ctx.strokeStyle = rightColor; + ctx.beginPath(); + const rightCenterY = topHeight / 2; + for (let x = 0; x < halfWidth; x++) { + const sampleIndex = Math.floor(startSample + x * samplesPerPixel); + if (sampleIndex >= totalSamples) break; + const sample = audioData.rightChannel[sampleIndex]; + const y = rightCenterY - sample * (topHeight * 0.35); + if (x === 0) ctx.moveTo(halfWidth + x, y); + else ctx.lineTo(halfWidth + x, y); + } + ctx.stroke(); + + // XY (bottom half) + ctx.strokeStyle = xyColor; + ctx.beginPath(); + const xyCenterX = width / 2; + const xyCenterY = topHeight + bottomHeight / 2; + const xyScale = Math.min(halfWidth, bottomHeight) * 0.35; + for (let i = startSample; i < endSample; i++) { + const x = xyCenterX + audioData.leftChannel[i] * xyScale; + const y = xyCenterY - audioData.rightChannel[i] * xyScale; + if (i === startSample) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Dividers + ctx.strokeStyle = dividerColor; + ctx.beginPath(); + ctx.moveTo(0, topHeight); + ctx.lineTo(width, topHeight); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(halfWidth, 0); + ctx.lineTo(halfWidth, topHeight); + ctx.stroke(); + } + }; + + // Setup recording + const videoStream = canvas.captureStream(fps); + const audioContext = new AudioContext(); + await audioContext.resume(); + + const audioArrayBuffer = await audioFile.arrayBuffer(); + const audioBuffer = await audioContext.decodeAudioData(audioArrayBuffer); + + const audioSource = audioContext.createBufferSource(); + audioSource.buffer = audioBuffer; + const audioDestination = audioContext.createMediaStreamDestination(); + audioSource.connect(audioDestination); + + const combinedStream = new MediaStream([ + ...videoStream.getVideoTracks(), + ...audioDestination.stream.getAudioTracks(), + ]); + + let mimeType = 'video/webm;codecs=vp8,opus'; + if (!MediaRecorder.isTypeSupported(mimeType)) { + mimeType = 'video/webm;codecs=vp9,opus'; + } + if (!MediaRecorder.isTypeSupported(mimeType)) { + mimeType = 'video/webm'; + } + + const mediaRecorder = new MediaRecorder(combinedStream, { + mimeType, + videoBitsPerSecond: 8000000, + audioBitsPerSecond: 256000, + }); + + const chunks: Blob[] = []; + + return new Promise((resolve, reject) => { + let stopped = false; + + const stopRecorder = (reason: string) => { + if (stopped) return; + stopped = true; + log('stopRecorder', reason); + + if (mediaRecorder.state === 'recording') { + mediaRecorder.stop(); + } + }; + + mediaRecorder.ondataavailable = (e) => { + log('ondataavailable', { size: e.data?.size, type: e.data?.type }); + if (e.data && e.data.size > 0) { + chunks.push(e.data); + } + }; + + mediaRecorder.onstop = async () => { + log('onstop', { chunks: chunks.length }); + await audioContext.close(); + combinedStream.getTracks().forEach(t => t.stop()); + + const blob = new Blob(chunks, { type: mimeType }); + log('final blob', { size: blob.size }); + + if (blob.size === 0) { + reject(new Error('Empty blob')); + return; + } + + resolve(URL.createObjectURL(blob)); + }; + + mediaRecorder.onerror = (e) => reject(e); + + audioSource.onended = () => { + log('audioSource.onended'); + renderFrame(Math.max(0, totalSamples - samplesPerFrame)); + stopRecorder('audio_ended'); + }; + + // Start recording + mediaRecorder.start(); + const exportStart = audioContext.currentTime; + audioSource.start(0); + log('started', { duration: audioBuffer.duration }); + + // Safety timeout + setTimeout(() => stopRecorder('timeout'), (audioBuffer.duration + 30) * 1000); + + // Render loop + let lastFrame = -1; + const loop = () => { + if (stopped) return; + + const t = Math.max(0, audioContext.currentTime - exportStart); + const frameIndex = Math.floor(t * fps); + + if (frameIndex !== lastFrame) { + renderFrame(Math.min(frameIndex * samplesPerFrame, totalSamples - 1)); + lastFrame = frameIndex; + } + + requestAnimationFrame(loop); + }; + requestAnimationFrame(loop); + }); + } + + async validateBlob(url: string): Promise { + const issues: string[] = []; + + try { + const response = await fetch(url); + const blob = await response.blob(); + + if (blob.size === 0) { + issues.push('Blob is empty'); + } + + if (!blob.type.includes('webm')) { + issues.push(`Unexpected MIME type: ${blob.type}`); + } + + // Check WebM magic bytes + const header = await blob.slice(0, 4).arrayBuffer(); + const bytes = new Uint8Array(header); + // WebM starts with 0x1A 0x45 0xDF 0xA3 (EBML header) + if (bytes[0] !== 0x1A || bytes[1] !== 0x45 || bytes[2] !== 0xDF || bytes[3] !== 0xA3) { + issues.push('Invalid WebM header (missing EBML magic bytes)'); + } + + return { + valid: issues.length === 0, + size: blob.size, + type: blob.type, + issues, + }; + } catch (error) { + return { + valid: false, + size: 0, + type: '', + issues: [error instanceof Error ? error.message : String(error)], + }; + } + } + + downloadBlob(url: string, filename: string = 'test-export.webm') { + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + } +} + +// Expose globally for testing +const api = new VideoExportTestAPIClass(); + +declare global { + interface Window { + VideoExportTestAPI: VideoExportTestAPIClass; + } +} + +if (typeof window !== 'undefined') { + window.VideoExportTestAPI = api; +} + +export const VideoExportTestAPI = api;