From e74852abecaf3c22af863c7c6ee923521f8dafcf Mon Sep 17 00:00:00 2001 From: "haixin.yang" Date: Mon, 13 Jul 2026 14:01:51 +0800 Subject: [PATCH] release: modular editor reliability v0.2.0 --- .github/workflows/ci.yml | 25 + .gitignore | 7 + .prettierignore | 5 + .prettierrc.json | 5 + README.md | 2 +- README.zh-CN.md | 2 +- docs/star-history.svg | 57 + eslint.config.js | 44 + package-lock.json | 1999 +++++- package.json | 30 +- public/model-cache-sw.js | 7 +- src/App.jsx | 5395 ++--------------- src/components/EditorOverlays.jsx | 24 + src/components/EditorSidebar.jsx | 114 + src/components/panels.jsx | 8 +- src/config/editor.js | 2 - src/hooks/useAudioTrackState.js | 43 + src/hooks/useAutoCaptions.js | 25 + src/hooks/useAutosaveTimestamp.js | 11 + src/hooks/useAvatarGeneration.js | 64 + src/hooks/useCaptionState.js | 35 + src/hooks/useEditorCatalog.js | 16 + src/hooks/useEditorHistory.js | 271 + src/hooks/useEditorLifecycle.js | 164 + src/hooks/useEditorRefs.js | 47 + src/hooks/useEditorUiState.js | 59 + src/hooks/useExportElapsed.js | 10 + src/hooks/useFileUpload.js | 48 + src/hooks/useMediaSync.js | 59 + src/hooks/usePreviewFrameSize.js | 20 + src/hooks/usePreviewModel.js | 121 + src/hooks/useProjectFiles.js | 89 + src/hooks/useSourceAudioExtraction.js | 17 + src/hooks/useTimelineModel.js | 205 + src/hooks/useToast.js | 11 + src/hooks/useVideoExport.js | 61 + src/hooks/useVisionAnalysis.js | 71 + src/hooks/useVisualTrackState.js | 28 + src/hooks/useVoiceGeneration.js | 47 + src/hooks/useVoiceRecorder.js | 56 + .../__fixtures__/livePortraitFrameSamples.js | 18 + src/lib/assetDragControls.js | 100 + src/lib/assetDropActions.js | 90 + src/lib/assetLibraryActions.js | 37 + src/lib/audioClipActions.js | 39 + src/lib/audioTrackActions.js | 177 + src/lib/captionEditingActions.js | 145 + src/lib/editorCommandActions.js | 57 + src/lib/editorHistoryCore.test.js | 37 + src/lib/editorHistoryCore.ts | 50 + src/lib/editorRuntime.js | 123 + src/lib/imageResizeControl.js | 60 + src/lib/livePortraitQuality.test.js | 57 + src/lib/livePortraitQuality.ts | 88 + src/lib/media.js | 25 +- src/lib/playbackControls.js | 61 + src/lib/stickerTimelineActions.js | 78 + src/lib/timeline.test.js | 72 + src/lib/timelineClipboardActions.js | 74 + src/lib/timelineCutActions.js | 55 + src/lib/timelineDurationActions.js | 48 + src/lib/timelineMoveControls.js | 58 + src/lib/timelineReorderControls.js | 58 + src/lib/timelineSegmentCountActions.js | 61 + src/lib/timelineViewModel.js | 84 + src/lib/visionControls.js | 68 + src/lib/visualGeometry.test.js | 48 + src/lib/visualTimelineActions.js | 177 + src/workers/liveportrait.worker.js | 10 +- tsconfig.json | 16 + 70 files changed, 6412 insertions(+), 4963 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 docs/star-history.svg create mode 100644 eslint.config.js create mode 100644 src/components/EditorOverlays.jsx create mode 100644 src/components/EditorSidebar.jsx create mode 100644 src/hooks/useAudioTrackState.js create mode 100644 src/hooks/useAutoCaptions.js create mode 100644 src/hooks/useAutosaveTimestamp.js create mode 100644 src/hooks/useAvatarGeneration.js create mode 100644 src/hooks/useCaptionState.js create mode 100644 src/hooks/useEditorCatalog.js create mode 100644 src/hooks/useEditorHistory.js create mode 100644 src/hooks/useEditorLifecycle.js create mode 100644 src/hooks/useEditorRefs.js create mode 100644 src/hooks/useEditorUiState.js create mode 100644 src/hooks/useExportElapsed.js create mode 100644 src/hooks/useFileUpload.js create mode 100644 src/hooks/useMediaSync.js create mode 100644 src/hooks/usePreviewFrameSize.js create mode 100644 src/hooks/usePreviewModel.js create mode 100644 src/hooks/useProjectFiles.js create mode 100644 src/hooks/useSourceAudioExtraction.js create mode 100644 src/hooks/useTimelineModel.js create mode 100644 src/hooks/useToast.js create mode 100644 src/hooks/useVideoExport.js create mode 100644 src/hooks/useVisionAnalysis.js create mode 100644 src/hooks/useVisualTrackState.js create mode 100644 src/hooks/useVoiceGeneration.js create mode 100644 src/hooks/useVoiceRecorder.js create mode 100644 src/lib/__fixtures__/livePortraitFrameSamples.js create mode 100644 src/lib/assetDragControls.js create mode 100644 src/lib/assetDropActions.js create mode 100644 src/lib/assetLibraryActions.js create mode 100644 src/lib/audioClipActions.js create mode 100644 src/lib/audioTrackActions.js create mode 100644 src/lib/captionEditingActions.js create mode 100644 src/lib/editorCommandActions.js create mode 100644 src/lib/editorHistoryCore.test.js create mode 100644 src/lib/editorHistoryCore.ts create mode 100644 src/lib/editorRuntime.js create mode 100644 src/lib/imageResizeControl.js create mode 100644 src/lib/livePortraitQuality.test.js create mode 100644 src/lib/livePortraitQuality.ts create mode 100644 src/lib/playbackControls.js create mode 100644 src/lib/stickerTimelineActions.js create mode 100644 src/lib/timeline.test.js create mode 100644 src/lib/timelineClipboardActions.js create mode 100644 src/lib/timelineCutActions.js create mode 100644 src/lib/timelineDurationActions.js create mode 100644 src/lib/timelineMoveControls.js create mode 100644 src/lib/timelineReorderControls.js create mode 100644 src/lib/timelineSegmentCountActions.js create mode 100644 src/lib/timelineViewModel.js create mode 100644 src/lib/visionControls.js create mode 100644 src/lib/visualGeometry.test.js create mode 100644 src/lib/visualTimelineActions.js create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b680b39 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm run test + - run: npm run build diff --git a/.gitignore b/.gitignore index 6205813..37f5e64 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # build output dist/ +coverage/ # local QA captures and generated media qa/ @@ -11,6 +12,7 @@ AGENTS.md # local package/cache files .npm-cache/ +.cache/ .npmrc .netlify/ .vite/ @@ -30,7 +32,12 @@ pnpm-debug.log* # OS/editor noise .DS_Store +**/.DS_Store Thumbs.db +desktop.ini +*.swp +*.swo +*~ .idea/ .vscode/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7dbb1fa --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist +node_modules +.npm-cache +public/vendor +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..b68d0e1 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "printWidth": 100, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/README.md b/README.md index 65cfb81..c295dfc 100644 --- a/README.md +++ b/README.md @@ -220,4 +220,4 @@ Released under the [MIT License](LICENSE). You may use, modify, and distribute t If Timeline Studio is useful to you, starring the repository helps more people discover it. -[![Star History Chart](https://api.star-history.com/svg?repos=MartinDelophy/ai-video-editor&type=Date)](https://star-history.com/#MartinDelophy/ai-video-editor&Date) +[![Timeline Studio GitHub Star History](docs/star-history.svg)](https://github.com/MartinDelophy/ai-video-editor/stargazers) diff --git a/README.zh-CN.md b/README.zh-CN.md index 313c87c..e9edb7f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -205,4 +205,4 @@ npx netlify-cli deploy --prod --dir=dist 如果 Timeline Studio 对你有帮助,欢迎点一个 Star,这会帮助更多人发现这个项目。 -[![Star History Chart](https://api.star-history.com/svg?repos=MartinDelophy/ai-video-editor&type=Date)](https://star-history.com/#MartinDelophy/ai-video-editor&Date) +[![Timeline Studio GitHub Star 趋势](docs/star-history.svg)](https://github.com/MartinDelophy/ai-video-editor/stargazers) diff --git a/docs/star-history.svg b/docs/star-history.svg new file mode 100644 index 0000000..1a2403f --- /dev/null +++ b/docs/star-history.svg @@ -0,0 +1,57 @@ + + Timeline Studio GitHub Star History + Timeline Studio grew from zero to fourteen GitHub stars between July 9 and July 13, 2026. + + + + + + + + + + + + + GitHub Stars over time + 14 stars + + + + + + + + + 0 + 4 + 8 + 12 + 16 + Jul 9 + Jul 10 + Jul 11 + Jul 12 + Jul 13 + + + + + diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..b317a9f --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,44 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; + +export default [ + { + ignores: ["dist/**", "node_modules/**", "public/vendor/**", ".npm-cache/**"], + }, + js.configs.recommended, + { + files: ["**/*.{js,jsx,mjs}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.browser, + ...globals.worker, + ...globals.node, + }, + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + "no-control-regex": "off", + "no-useless-escape": "off", + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + }, + }, + { + files: ["**/*.test.js", "**/*.test.jsx"], + languageOptions: { + globals: globals.node, + }, + }, +]; diff --git a/package-lock.json b/package-lock.json index 293848c..2797fb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,32 +1,45 @@ { "name": "web-player", - "version": "0.0.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "web-player", - "version": "0.0.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@diffusionstudio/vits-web": "^1.0.3", + "@ffmpeg/core": "^0.12.10", "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", "@huggingface/transformers": "^3.8.1", "@phosphor-icons/react": "^2.1.10", - "@vitejs/plugin-react": "5.0.4", "fflate": "^0.8.3", "kokoro-js": "^1.2.1", "onnxruntime-web": "^1.27.0", "react": "19.2.0", - "react-dom": "19.2.0", - "vite": "6.4.2" + "react-dom": "19.2.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@vitejs/plugin-react": "^5.0.4", + "@vitest/coverage-v8": "^4.1.10", + "eslint": "^10.7.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.7.0", + "prettier": "^3.9.5", + "typescript": "^7.0.2", + "vite": "^6.4.2", + "vitest": "^4.1.10" } }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -41,6 +54,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz", "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -50,6 +64,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -80,6 +95,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.7.tgz", "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", @@ -96,6 +112,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.29.7", @@ -112,6 +129,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -121,6 +139,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -134,6 +153,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.29.7", @@ -151,6 +171,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -160,6 +181,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -169,6 +191,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -178,6 +201,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -187,6 +211,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.7.tgz", "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.29.7", @@ -200,6 +225,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -215,6 +241,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -230,6 +257,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -245,6 +273,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -259,6 +288,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.7.tgz", "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -277,6 +307,7 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -286,6 +317,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@diffusionstudio/vits-web": { "version": "1.0.3", "resolved": "https://registry.npmmirror.com/@diffusionstudio/vits-web/-/vits-web-1.0.3.tgz", @@ -338,6 +379,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -354,6 +396,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -370,6 +413,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -386,6 +430,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -402,6 +447,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -418,6 +464,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -434,6 +481,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -450,6 +498,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -466,6 +515,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -482,6 +532,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -498,6 +549,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -514,6 +566,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -530,6 +583,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -546,6 +600,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -562,6 +617,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -578,6 +634,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -594,6 +651,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -610,6 +668,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -626,6 +685,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -642,6 +702,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -658,6 +719,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -674,6 +736,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -690,6 +753,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -706,6 +770,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -722,6 +787,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -738,6 +804,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -747,6 +814,143 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@ffmpeg/core": { + "version": "0.12.10", + "resolved": "https://registry.npmmirror.com/@ffmpeg/core/-/core-0.12.10.tgz", + "integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=16.x" + } + }, "node_modules/@ffmpeg/ffmpeg": { "version": "0.12.15", "resolved": "https://registry.npmmirror.com/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz", @@ -818,6 +1022,72 @@ "protobufjs": "^7.2.4" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmmirror.com/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@img/colour/-/colour-1.1.0.tgz", @@ -1299,6 +1569,7 @@ "version": "0.3.13", "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1309,6 +1580,7 @@ "version": "2.3.5", "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1319,6 +1591,7 @@ "version": "3.1.2", "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1328,12 +1601,14 @@ "version": "1.5.5", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1414,6 +1689,7 @@ "version": "1.0.0-beta.38", "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==", + "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { @@ -1423,6 +1699,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1436,6 +1713,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1449,6 +1727,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1462,6 +1741,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1475,6 +1755,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1488,6 +1769,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1501,6 +1783,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1514,6 +1797,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1527,6 +1811,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1540,6 +1825,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1553,6 +1839,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1566,6 +1853,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1579,6 +1867,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1592,6 +1881,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1605,6 +1895,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1618,6 +1909,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1631,6 +1923,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1644,6 +1937,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1657,6 +1951,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1670,6 +1965,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1683,6 +1979,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1696,6 +1993,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1709,6 +2007,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1722,6 +2021,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1735,16 +2035,25 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", @@ -1758,6 +2067,7 @@ "version": "7.27.0", "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" @@ -1767,6 +2077,7 @@ "version": "7.4.4", "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", @@ -1777,15 +2088,49 @@ "version": "7.28.0", "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -1797,10 +2142,351 @@ "undici-types": "~8.3.0" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.0.4", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz", "integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.28.4", @@ -1817,10 +2503,234 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.42", "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -1836,10 +2746,24 @@ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/browserslist": { "version": "4.28.5", "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.5.tgz", "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -1873,6 +2797,7 @@ "version": "1.0.30001803", "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -1889,6 +2814,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz", @@ -1902,12 +2837,29 @@ "version": "2.0.0", "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1921,6 +2873,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", @@ -1974,6 +2933,7 @@ "version": "1.5.389", "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, "license": "ISC" }, "node_modules/es-define-property": { @@ -1994,6 +2954,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmmirror.com/es6-error/-/es6-error-4.1.1.tgz", @@ -2004,6 +2971,7 @@ "version": "0.25.12", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -2045,6 +3013,7 @@ "version": "3.2.0", "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2062,10 +3031,237 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint": { + "version": "10.7.0", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.3", + "resolved": "https://registry.npmmirror.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmmirror.com/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2085,16 +3281,68 @@ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "license": "MIT" }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/flatbuffers": { "version": "25.9.23", "resolved": "https://registry.npmmirror.com/flatbuffers/-/flatbuffers-25.9.23.tgz", "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", "license": "Apache-2.0" }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2109,11 +3357,25 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/global-agent": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/global-agent/-/global-agent-3.0.0.tgz", @@ -2143,6 +3405,19 @@ "node": ">=10" } }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz", @@ -2177,6 +3452,16 @@ "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -2189,16 +3474,131 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -2207,6 +3607,27 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmmirror.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -2217,6 +3638,7 @@ "version": "2.2.3", "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -2225,6 +3647,16 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kokoro-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/kokoro-js/-/kokoro-js-1.2.1.tgz", @@ -2235,6 +3667,36 @@ "phonemizer": "^1.2.1" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", @@ -2245,11 +3707,63 @@ "version": "5.1.1", "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmmirror.com/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/matcher/-/matcher-3.0.0.tgz", @@ -2262,6 +3776,22 @@ "node": ">=10" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", @@ -2287,12 +3817,14 @@ "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz", "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, "funding": [ { "type": "github", @@ -2307,10 +3839,18 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.50", "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.50.tgz", "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2325,6 +3865,20 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/onnxruntime-common": { "version": "1.21.0", "resolved": "https://registry.npmmirror.com/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz", @@ -2368,6 +3922,83 @@ "integrity": "sha512-3KxL5wIVqa8Ex08jxSzncm9CMgw8CjOFyOQ7SxvG9o0cVLlhTNKXyIQuTbtX4tGPJEf73OER2xrjt4HJSBL4ow==", "license": "MIT" }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/phonemizer": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/phonemizer/-/phonemizer-1.2.1.tgz", @@ -2378,12 +4009,14 @@ "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.5", "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2402,6 +4035,7 @@ "version": "8.5.16", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.16.tgz", "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -2426,6 +4060,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/protobufjs": { "version": "7.6.5", "resolved": "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.6.5.tgz", @@ -2449,6 +4109,16 @@ "node": ">=12.0.0" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmmirror.com/react/-/react-19.2.0.tgz", @@ -2474,6 +4144,7 @@ "version": "0.17.0", "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -2500,6 +4171,7 @@ "version": "4.62.2", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.9" @@ -2550,6 +4222,7 @@ "version": "6.3.1", "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2632,10 +4305,41 @@ "node": ">=10" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -2647,6 +4351,33 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tar": { "version": "7.5.19", "resolved": "https://registry.npmmirror.com/tar/-/tar-7.5.19.tgz", @@ -2672,10 +4403,28 @@ "node": ">=18" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -2688,6 +4437,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", @@ -2695,6 +4454,19 @@ "license": "0BSD", "optional": true }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz", @@ -2707,6 +4479,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.3.0.tgz", @@ -2717,6 +4524,7 @@ "version": "1.2.3", "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, "funding": [ { "type": "opencollective", @@ -2743,10 +4551,21 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vite": { "version": "6.4.2", "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -2817,11 +4636,181 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index 392ccaf..df63ede 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "web-player", - "version": "0.0.0", + "version": "0.2.0", "private": true, "description": "A local-first browser AI video editor for voiceovers, captions, talking avatars, and multi-track timeline export.", "license": "MIT", @@ -16,20 +16,40 @@ "scripts": { "dev": "vite --host 127.0.0.1", "build": "vite build", - "preview": "vite preview --host 127.0.0.1" + "preview": "vite preview --host 127.0.0.1", + "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --noEmit", + "check": "npm run lint && npm run typecheck && npm run test && npm run build" }, "dependencies": { "@diffusionstudio/vits-web": "^1.0.3", + "@ffmpeg/core": "^0.12.10", "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", "@huggingface/transformers": "^3.8.1", "@phosphor-icons/react": "^2.1.10", - "@vitejs/plugin-react": "5.0.4", "fflate": "^0.8.3", "kokoro-js": "^1.2.1", "onnxruntime-web": "^1.27.0", "react": "19.2.0", - "react-dom": "19.2.0", - "vite": "6.4.2" + "react-dom": "19.2.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@vitejs/plugin-react": "^5.0.4", + "@vitest/coverage-v8": "^4.1.10", + "eslint": "^10.7.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.7.0", + "prettier": "^3.9.5", + "typescript": "^7.0.2", + "vite": "^6.4.2", + "vitest": "^4.1.10" } } diff --git a/public/model-cache-sw.js b/public/model-cache-sw.js index 54bd8ea..04b1a19 100644 --- a/public/model-cache-sw.js +++ b/public/model-cache-sw.js @@ -27,8 +27,6 @@ const HUGGING_FACE_HOSTS = new Set([ "cdn-lfs-us-1.hf.co", "cdn-lfs-eu-1.hf.co", ]); -const CDN_HOSTS = new Set(["cdn.jsdelivr.net"]); - function hasCacheableExtension(pathname) { return CACHEABLE_EXTENSIONS.some((extension) => pathname.endsWith(extension)); } @@ -47,10 +45,7 @@ function isRuntimeAssetRequest(url) { || (url.pathname.startsWith("/assets/") && hasCacheableExtension(url.pathname)); } - return CDN_HOSTS.has(url.hostname) && ( - url.pathname.includes("/@ffmpeg/") || - hasCacheableExtension(url.pathname) - ); + return false; } function shouldCacheRequest(request) { diff --git a/src/App.jsx b/src/App.jsx index dd43efd..d682abd 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,375 +1,105 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; -import { - ASSET_DRAG_MIME, - DEFAULT_STICKER_SEGMENT_SECONDS, - DEFAULT_TIMELINE_DURATION_SECONDS, - DEFAULT_SCRIPT, - FILTER_OPTIONS, - IMAGE_RESIZE_OVERFLOW_SECONDS_PER_PIXEL, - IMAGE_SEGMENT_SECONDS, - IMAGE_SNAP_THRESHOLD_PIXELS, - MAX_TIMELINE_DURATION_SECONDS, - MIN_VISUAL_SEGMENT_SECONDS, - MODEL_ID, - RATIO_OPTIONS, - SAMPLE_IMAGE, - STICKERS, - SUPPORTED_MEDIA_TYPES, - TOOL_RAIL, - VISUAL_STYLE_OPTIONS, - VOICES, -} from "./config/editor.js"; -import { LanguageIntro, MediaPanel, ToolPanel } from "./components/panels.jsx"; +import { LanguageIntro } from "./components/panels.jsx"; import { PreviewStage } from "./components/PreviewStage.jsx"; import { VoicePanel } from "./components/VoicePanel.jsx"; import { Timeline } from "./components/Timeline.jsx"; import { Topbar } from "./components/Topbar.jsx"; -import { createTranslator, getStoredLanguage, saveLanguagePreference, translateOptionName } from "./i18n.js"; -import { transcribeAudioToCaptionSegments } from "./lib/asr.js"; -import { getCaptionTextLayout } from "./lib/captionLayout.js"; -import { - createCaptionSegments, - createStickerSegment, - createVisualSegment, - estimateDuration, - formatClock, - formatSavedTime, - formatTime, - getCaptionScript, - getCaptionTimeline, - hasExplicitCaptionTiming, - getImageThumbnailCount, - getScriptSegments, - getSegmentIndexAtTime, - getSegmentStartTime, - getTimedSegmentIndexAtTime, - getTimedSegmentsEnd, - getVisualAssetPayload, - getVisualSegmentIndexAtTime, - getVisualSegmentTimeline, - getVisualSegmentsTotal, - makeId, - reorderTimelineItems, -} from "./lib/timeline.js"; -import { - decodeWaveform, - downloadBlob, - exportBrowserVideo, - extractVideoTrackFrames, - extractAudioFromVideo, - getAudioRecordingFormat, - getSupportedRecordingFormat, - reverseAudioBlob, - transcodeWebmToMp4, -} from "./lib/media.js"; -import { createProjectArchive, readProjectArchive, readProjectFileAsText } from "./lib/projectArchive.js"; -import { - clearPiperCacheIfStorageTight, - isPiperSymbolError, - isStorageQuotaError, - prepareTextForVoice, - TtsInputError, -} from "./lib/ttsText.js"; -import { - analyzeVideoVisualTrack, - analyzeVisualSubject, - captureVisualFrame, - disposeVisionWorker, - getVisionKey, - resolveVisionAnalysisAtTime, -} from "./lib/vision.js"; -import { - getCaptionAvoidancePlacement, - getSmartCropRect, - mapNormalizedBoxToFrame, -} from "./lib/visualGeometry.js"; -import { JOYVASA_PROJECT_MODEL_BASE_URL } from "./config/joyVasa.js"; -import { LIVE_PORTRAIT_WEBGPU_PROJECT_MODEL_BASE_URL } from "./config/livePortrait.js"; - -const PLAYBACK_UI_FRAME_MS = 50; -const DEFAULT_VISION_OPTIONS = Object.freeze({ - showDetections: true, - removeBackground: false, - avoidCaptions: true, - smartCrop: true, -}); -const EMPTY_VISION_OPTIONS = Object.freeze({ - showDetections: false, - removeBackground: false, - avoidCaptions: false, - smartCrop: false, -}); - -function getAudioSegmentPreviewVolume(segment, timelineTime) { - const volume = Math.max(0, Math.min(1, segment.volume ?? 1)); - const localTime = Math.max(0, Math.min(segment.duration, timelineTime - segment.start)); - const fadeIn = Math.max(0, Math.min(segment.duration, segment.fadeIn || 0)); - const fadeOut = Math.max(0, Math.min(segment.duration, segment.fadeOut || 0)); - const fadeInGain = fadeIn > 0 ? Math.min(1, localTime / fadeIn) : 1; - const fadeOutGain = fadeOut > 0 ? Math.min(1, (segment.duration - localTime) / fadeOut) : 1; - return volume * Math.max(0, Math.min(fadeInGain, fadeOutGain)); -} - -async function decodeAvatarAudio16k(blob) { - const AudioContextClass = window.AudioContext || window.webkitAudioContext; - if (!AudioContextClass) throw new Error("当前浏览器不支持音频解码"); - const context = new AudioContextClass(); - try { - const decoded = await context.decodeAudioData((await blob.arrayBuffer()).slice(0)); - const mono = new Float32Array(decoded.length); - for (let channel = 0; channel < decoded.numberOfChannels; channel += 1) { - const values = decoded.getChannelData(channel); - for (let i = 0; i < mono.length; i += 1) mono[i] += values[i] / decoded.numberOfChannels; - } - const outputLength = Math.min(64_000, Math.ceil(decoded.duration * 16_000)); - const resampled = new Float32Array(64_000); - for (let i = 0; i < outputLength; i += 1) { - const position = (i * decoded.sampleRate) / 16_000; - const left = Math.min(mono.length - 1, Math.floor(position)); - const right = Math.min(mono.length - 1, left + 1); - const ratio = position - left; - resampled[i] = mono[left] * (1 - ratio) + mono[right] * ratio; - } - return resampled; - } finally { - await context.close().catch(() => {}); - } -} - -function runAvatarWorkerTask(worker, message, transfer, terminalType, onProgress) { - return new Promise((resolve, reject) => { - worker.onmessage = (event) => { - if (event.data?.type === "progress") { - onProgress?.(event.data); - return; - } - if (event.data?.type === "error") { - reject(new Error(event.data.message)); - return; - } - if (event.data?.type === terminalType) resolve(event.data); - }; - worker.onerror = (event) => reject(new Error(event.message || "Worker error")); - worker.postMessage(message, transfer); - }); -} - -function formatAvatarProgress(t, progress) { - const template = progress.phaseKey ? t(progress.phaseKey) : progress.phase || t("avatarGenerating"); - return Object.entries(progress.phaseParams || {}).reduce( - (text, [key, value]) => text.replaceAll(`{${key}}`, String(value)), - template, - ); -} - -async function encodeAvatarFrames(blobs, width, height, fps, keyframeTimes = [], duration = blobs.length / fps) { - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const context = canvas.getContext("2d", { alpha: false }); - const stream = canvas.captureStream(fps); - const mimeType = ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"] - .find((type) => MediaRecorder.isTypeSupported(type)); - const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); - const chunks = []; - recorder.ondataavailable = (event) => { if (event.data.size) chunks.push(event.data); }; - const stopped = new Promise((resolve, reject) => { - recorder.onstop = () => resolve(new Blob(chunks, { type: recorder.mimeType || "video/webm" })); - recorder.onerror = () => reject(recorder.error || new Error("数字人视频编码失败")); - }); - recorder.start(); - const bitmaps = await Promise.all(blobs.map((blob) => createImageBitmap(blob))); - const totalFrames = Math.max(1, Math.ceil(duration * fps)); - for (let frame = 0; frame < totalFrames; frame += 1) { - const frameTime = frame / fps; - let nearestIndex = 0; - let nearestDistance = Number.POSITIVE_INFINITY; - for (let index = 0; index < bitmaps.length; index += 1) { - const time = keyframeTimes[index] ?? (index * duration) / Math.max(1, bitmaps.length - 1); - const distance = Math.abs(time - frameTime); - if (distance < nearestDistance) { - nearestDistance = distance; - nearestIndex = index; - } - } - // Never alpha-blend two complete portraits: even tiny head motion produces - // double eyes/mouth and the "ghost face" artifact. Adaptive keyframes make - // the held frame intervals short while preserving a single coherent face. - context.drawImage(bitmaps[nearestIndex], 0, 0, width, height); - await new Promise((resolve) => window.setTimeout(resolve, 1000 / fps)); - } - bitmaps.forEach((bitmap) => bitmap.close()); - recorder.stop(); - const output = await stopped; - stream.getTracks().forEach((track) => track.stop()); - if (!output.size) throw new Error("数字人视频编码结果为空"); - return output; -} - -function getNearestRatioIdForSize(width, height) { - const mediaWidth = Number(width); - const mediaHeight = Number(height); - if (!Number.isFinite(mediaWidth) || !Number.isFinite(mediaHeight) || mediaWidth <= 0 || mediaHeight <= 0) { - return ""; - } - - const mediaRatio = mediaWidth / mediaHeight; - return RATIO_OPTIONS.reduce( - (best, option) => { - const optionRatio = option.width / option.height; - const distance = Math.abs(Math.log(mediaRatio / optionRatio)); - return distance < best.distance ? { id: option.id, distance } : best; - }, - { id: RATIO_OPTIONS[0]?.id ?? "", distance: Number.POSITIVE_INFINITY }, - ).id; -} - -function getObjectPositionForCrop(cropRect) { - const normalized = cropRect?.normalized; - if (!normalized) { - return "50% 50%"; - } - - const horizontalRange = Math.max(0, 1 - normalized.width); - const verticalRange = Math.max(0, 1 - normalized.height); - const x = horizontalRange > 0.0001 ? (normalized.xMin / horizontalRange) * 100 : 50; - const y = verticalRange > 0.0001 ? (normalized.yMin / verticalRange) * 100 : 50; - return `${Math.max(0, Math.min(100, x))}% ${Math.max(0, Math.min(100, y))}%`; -} - -function isSameVisionDetection(detection, subject) { - if (!detection?.box || !subject?.box || detection.label !== subject.label) { - return false; - } - - return [ - ["xMin", "xmin"], - ["yMin", "ymin"], - ["xMax", "xmax"], - ["yMax", "ymax"], - ].every(([camelKey, lowerKey]) => - Math.abs( - (detection.box[camelKey] ?? detection.box[lowerKey] ?? 0) - - (subject.box[camelKey] ?? subject.box[lowerKey] ?? 0), - ) < 0.004, - ); -} - -function revokeVisionObjectUrls(value) { - const urls = Array.isArray(value) ? value : value ? [value] : []; - urls.forEach((url) => URL.revokeObjectURL(url)); -} - -function getTimelineTrackLocalTime(time, start = 0, duration = 0) { - return Math.max(0, Math.min(duration || 0, time - start)); -} - -function isTimelineTimeInsideTrack(time, start = 0, duration = 0) { - return duration > 0 && time >= start && time <= start + duration; -} +import { AssetDragPreview, ExportProgressOverlay } from "./components/EditorOverlays.jsx"; +import { EditorSidebar } from "./components/EditorSidebar.jsx"; +import { useExportElapsed } from "./hooks/useExportElapsed.js"; +import { usePreviewFrameSize } from "./hooks/usePreviewFrameSize.js"; +import { useEditorCatalog } from "./hooks/useEditorCatalog.js"; +import { useToast } from "./hooks/useToast.js"; +import { useProjectFiles } from "./hooks/useProjectFiles.js"; +import { useAutosaveTimestamp } from "./hooks/useAutosaveTimestamp.js"; +import { useVisionAnalysis } from "./hooks/useVisionAnalysis.js"; +import { useFileUpload } from "./hooks/useFileUpload.js"; +import { useMediaSync } from "./hooks/useMediaSync.js"; +import { useVideoExport } from "./hooks/useVideoExport.js"; +import { useVoiceRecorder } from "./hooks/useVoiceRecorder.js"; +import { useVoiceGeneration } from "./hooks/useVoiceGeneration.js"; +import { useAutoCaptions } from "./hooks/useAutoCaptions.js"; +import { useSourceAudioExtraction } from "./hooks/useSourceAudioExtraction.js"; +import { useAvatarGeneration } from "./hooks/useAvatarGeneration.js"; +import { useCaptionState } from "./hooks/useCaptionState.js"; +import { useAudioTrackState } from "./hooks/useAudioTrackState.js"; +import { useVisualTrackState } from "./hooks/useVisualTrackState.js"; +import { useEditorUiState } from "./hooks/useEditorUiState.js"; +import { useTimelineModel } from "./hooks/useTimelineModel.js"; +import { usePreviewModel } from "./hooks/usePreviewModel.js"; +import { useEditorRefs } from "./hooks/useEditorRefs.js"; +import { useEditorLifecycle } from "./hooks/useEditorLifecycle.js"; +import { useEditorHistory } from "./hooks/useEditorHistory.js"; +import { createVisionControls } from "./lib/visionControls.js"; +import { createAssetDragControls } from "./lib/assetDragControls.js"; +import { createAssetLibraryActions } from "./lib/assetLibraryActions.js"; +import { createPlaybackControls } from "./lib/playbackControls.js"; +import { createTimelineReorderControls } from "./lib/timelineReorderControls.js"; +import { createTimelineMoveControls } from "./lib/timelineMoveControls.js"; +import { createImageResizeControl } from "./lib/imageResizeControl.js"; +import { createTimelineClipboardActions } from "./lib/timelineClipboardActions.js"; +import { createTimelineCutActions } from "./lib/timelineCutActions.js"; +import { createTimelineSegmentCountActions } from "./lib/timelineSegmentCountActions.js"; +import { createTimelineDurationActions } from "./lib/timelineDurationActions.js"; +import { createAudioClipActions } from "./lib/audioClipActions.js"; +import { createCaptionEditingActions } from "./lib/captionEditingActions.js"; +import { createAudioTrackActions } from "./lib/audioTrackActions.js"; +import { createVisualTimelineActions } from "./lib/visualTimelineActions.js"; +import { createStickerTimelineActions } from "./lib/stickerTimelineActions.js"; +import { createAssetDropActions } from "./lib/assetDropActions.js"; +import { createEditorCommandActions } from "./lib/editorCommandActions.js"; +import { createTimelineViewModel } from "./lib/timelineViewModel.js"; +import { createTranslator, getStoredLanguage, translateOptionName } from "./i18n.js"; +import { downloadBlob } from "./lib/media.js"; export function App() { const [uiLanguage, setUiLanguage] = useState(() => getStoredLanguage()); const [introClosing, setIntroClosing] = useState(false); - const [script, setScript] = useState(DEFAULT_SCRIPT); - const [selectedVoiceId, setSelectedVoiceId] = useState(VOICES[0].id); - const [speed, setSpeed] = useState(1); - const [volume, setVolume] = useState(1); - const [imageSrc, setImageSrc] = useState(""); - const [imageName, setImageName] = useState(""); - const [imageMeta, setImageMeta] = useState(""); - const [visualType, setVisualType] = useState("image"); - const [audioSegments, setAudioSegments] = useState([]); - const [selectedAudioSegmentId, setSelectedAudioSegmentId] = useState(""); - const [timelineHorizon, setTimelineHorizon] = useState(DEFAULT_TIMELINE_DURATION_SECONDS); - const [musicBlob, setMusicBlob] = useState(null); - const [musicUrl, setMusicUrl] = useState(""); - const [musicName, setMusicName] = useState(""); - const [musicDuration, setMusicDuration] = useState(0); - const [musicPeaks, setMusicPeaks] = useState([]); - const [musicVolume, setMusicVolume] = useState(0.35); - const [sourceAudioBlob, setSourceAudioBlob] = useState(null); - const [sourceAudioUrl, setSourceAudioUrl] = useState(""); - const [sourceAudioName, setSourceAudioName] = useState(""); - const [sourceAudioDuration, setSourceAudioDuration] = useState(0); - const [sourceAudioPeaks, setSourceAudioPeaks] = useState([]); - const [sourceAudioVolume, setSourceAudioVolume] = useState(1); - const [sourceAudioStart, setSourceAudioStart] = useState(0); - const [status, setStatus] = useState("ready"); - const [statusText, setStatusText] = useState("模型待命"); - const [progress, setProgress] = useState(0); - const [isPlaying, setIsPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [isDragging, setIsDragging] = useState(false); - const [draggedAssetId, setDraggedAssetId] = useState(""); - const [assetDropTargetTrack, setAssetDropTargetTrack] = useState(""); - const [assetDropPosition, setAssetDropPosition] = useState({ track: "", percent: 50 }); - const [assetDropPulseTrack, setAssetDropPulseTrack] = useState(""); - const [assetDragPreview, setAssetDragPreview] = useState(null); - const [selectedLibraryAssetId, setSelectedLibraryAssetId] = useState(""); - const [exporting, setExporting] = useState(false); - const [exportProgress, setExportProgress] = useState(0); - const [exportPhase, setExportPhase] = useState(""); - const [exportElapsedSeconds, setExportElapsedSeconds] = useState(0); - const [activeTool, setActiveTool] = useState("media"); - const [mediaTab, setMediaTab] = useState("upload"); - const [voiceTab, setVoiceTab] = useState("synthesis"); - const [voiceFilter, setVoiceFilter] = useState("all"); - const [showVoiceFilter, setShowVoiceFilter] = useState(false); - const [ratioId, setRatioId] = useState("16:9"); - const [showRatioMenu, setShowRatioMenu] = useState(false); - const [fitMode, setFitMode] = useState("contain"); - const [showSettings, setShowSettings] = useState(false); - const [showFileMenu, setShowFileMenu] = useState(false); - const [compactRail, setCompactRail] = useState(false); - const [selectedTrack, setSelectedTrack] = useState("image"); - const [timelineZoom, setTimelineZoom] = useState(0.25); - const [trackVisibility, setTrackVisibility] = useState({ - image: true, - caption: true, - sticker: true, - source: true, - audio: true, - music: true, - }); - const [trackLocks, setTrackLocks] = useState({ - image: false, - caption: false, - sticker: false, - source: false, - audio: false, - music: false, - }); - const [captionPosition, setCaptionPosition] = useState("bottom"); - const [captionPlacement, setCaptionPlacement] = useState({ x: 50, y: 78 }); - const [captionSize, setCaptionSize] = useState(12); - const [captionStyle, setCaptionStyle] = useState({ backgroundColor: "#05080d", backgroundOpacity: 0.62, textColor: "#f5fbff", borderColor: "#35f0dd", borderWidth: 0, radius: 7, paddingX: 22, paddingY: 12, shadowOpacity: 0.45, effect: "normal" }); - const [captionsEnabled, setCaptionsEnabled] = useState(true); - const [captionSegments, setCaptionSegments] = useState(() => createCaptionSegments(DEFAULT_SCRIPT)); - const [selectedSegmentId, setSelectedSegmentId] = useState(""); - const [imageClipCount, setImageClipCount] = useState(0); - const [imageDuration, setImageDuration] = useState(0); - const [visualSegments, setVisualSegments] = useState([]); - const [selectedVisualSegmentId, setSelectedVisualSegmentId] = useState(""); - const [timelineClipDrag, setTimelineClipDrag] = useState(null); - const [snapGuide, setSnapGuide] = useState(null); - const [selectedFilterId, setSelectedFilterId] = useState("none"); - const [selectedTransitionId, setSelectedTransitionId] = useState("none"); - const [selectedStickerId, setSelectedStickerId] = useState("none"); - const [stickerSegments, setStickerSegments] = useState([]); - const [selectedStickerSegmentId, setSelectedStickerSegmentId] = useState(""); + const { + captionPlacement, captionPosition, captionSegments, captionSize, captionStyle, + captionsEnabled, script, selectedSegmentId, setCaptionPlacement, + setCaptionPosition, setCaptionSegments, setCaptionSize, setCaptionStyle, + setCaptionsEnabled, setScript, setSelectedSegmentId, + } = useCaptionState(); + const { + audioSegments, favoriteVoiceIds, historyItems, musicBlob, musicDuration, musicName, + musicPeaks, musicUrl, musicVolume, recordedVoices, recordingElapsed, recordingState, + selectedAudioSegmentId, selectedVoiceId, setAudioSegments, setFavoriteVoiceIds, + setHistoryItems, setMusicBlob, setMusicDuration, setMusicName, setMusicPeaks, + setMusicUrl, setMusicVolume, setRecordedVoices, setRecordingElapsed, + setRecordingState, setSelectedAudioSegmentId, setSelectedVoiceId, setSourceAudioBlob, + setSourceAudioDuration, setSourceAudioName, setSourceAudioPeaks, setSourceAudioStart, + setSourceAudioUrl, setSourceAudioVolume, setSpeed, setTimelineHorizon, setVolume, + sourceAudioBlob, sourceAudioDuration, sourceAudioName, sourceAudioPeaks, + sourceAudioStart, sourceAudioUrl, sourceAudioVolume, speed, timelineHorizon, volume, + } = useAudioTrackState(); + const { + fitMode, imageClipCount, imageDuration, imageMeta, imageName, imageSrc, + selectedFilterId, selectedStickerId, selectedStickerSegmentId, selectedTransitionId, + selectedVisualSegmentId, setFitMode, setImageClipCount, setImageDuration, setImageMeta, + setImageName, setImageSrc, setSelectedFilterId, setSelectedStickerId, + setSelectedStickerSegmentId, setSelectedTransitionId, setSelectedVisualSegmentId, + setStickerSegments, setVisualSegments, setVisualType, stickerSegments, visualSegments, + visualType, + } = useVisualTrackState(); + const { + activeTool, assetDragPreview, assetDropPosition, assetDropPulseTrack, + assetDropTargetTrack, compactRail, currentTime, draggedAssetId, exporting, exportPhase, + exportProgress, isDragging, isPlaying, mediaTab, progress, ratioId, + selectedLibraryAssetId, selectedTrack, setActiveTool, setAssetDragPreview, + setAssetDropPosition, setAssetDropPulseTrack, setAssetDropTargetTrack, setCompactRail, + setCurrentTime, setDraggedAssetId, setExporting, setExportPhase, setExportProgress, + setIsDragging, setIsPlaying, setMediaTab, setProgress, setRatioId, + setSelectedLibraryAssetId, setSelectedTrack, setShowFileMenu, setShowRatioMenu, + setShowSettings, setShowVoiceFilter, setSnapGuide, setStatus, setStatusText, + setTimelineClipDrag, setTimelineZoom, setTrackLocks, setTrackVisibility, setVoiceFilter, + setVoiceTab, showFileMenu, showRatioMenu, showSettings, showVoiceFilter, snapGuide, + status, statusText, timelineClipDrag, timelineZoom, trackLocks, trackVisibility, + voiceFilter, voiceTab, + } = useEditorUiState(); const [userAssets, setUserAssets] = useState([]); - const [favoriteVoiceIds, setFavoriteVoiceIds] = useState(["zh_CN-huayan-medium"]); - const [historyItems, setHistoryItems] = useState([]); - const [recordedVoices, setRecordedVoices] = useState([]); - const [recordingState, setRecordingState] = useState("idle"); - const [recordingElapsed, setRecordingElapsed] = useState(0); - const [undoStack, setUndoStack] = useState([]); - const [redoStack, setRedoStack] = useState([]); - const [toast, setToast] = useState(""); - const [lastSaved, setLastSaved] = useState(formatSavedTime()); - const [previewFrameSize, setPreviewFrameSize] = useState({ width: 0, height: 0 }); + const { notify, toast } = useToast(); const [previewVideoMediaTime, setPreviewVideoMediaTime] = useState(0); const [visionRecords, setVisionRecords] = useState({}); const [visionJob, setVisionJob] = useState({ @@ -380,58 +110,49 @@ export function App() { }); const [avatarPanelOpen, setAvatarPanelOpen] = useState(false); const [avatarJob, setAvatarJob] = useState({ running: false, progress: 0, phase: "" }); + const lastSaved = useAutosaveTimestamp([ + script, imageSrc, visualType, imageDuration, captionPlacement, selectedVoiceId, speed, + volume, musicName, musicDuration, musicVolume, sourceAudioName, sourceAudioDuration, + sourceAudioStart, sourceAudioVolume, ratioId, fitMode, selectedFilterId, selectedStickerId, + captionSegments, visualSegments, visionRecords, timelineZoom, + ]); - const fileInputRef = useRef(null); - const projectFileInputRef = useRef(null); - const previewShellRef = useRef(null); - const previewCanvasRef = useRef(null); - const audioRef = useRef(null); - const audioSegmentRefs = useRef(new Map()); - const sourceAudioRef = useRef(null); - const musicRef = useRef(null); - const previewVideoRef = useRef(null); - const trackScrollRef = useRef(null); - const timelineDurationRef = useRef(0); - const currentTimeRef = useRef(0); - const visualPlaybackFrameRef = useRef(0); - const visualPlaybackStartedAtRef = useRef(0); - const visualPlaybackStartTimeRef = useRef(0); - const visualPlaybackLastUpdateRef = useRef(0); - const audioUrlRef = useRef(""); - const sourceAudioUrlRef = useRef(""); - const musicUrlRef = useRef(""); - const imageUrlRefs = useRef(new Set()); - const toastTimerRef = useRef(0); - const exportStartRef = useRef(0); - const voiceRecorderRef = useRef(null); - const voiceRecorderChunksRef = useRef([]); - const voiceRecorderStreamRef = useRef(null); - const voiceRecorderStartedAtRef = useRef(0); - const voiceRecorderTimerRef = useRef(0); - const draggedAssetIdRef = useRef(""); - const pointerAssetDragRef = useRef(null); - const suppressAssetClickRef = useRef(""); - const assetDropPulseTimerRef = useRef(0); - const timelineClipDragRef = useRef(null); - const suppressTimelineClipClickRef = useRef(""); - const autoRatioSourceKeyRef = useRef(""); - const visionObjectUrlsRef = useRef(new Map()); - const visionAbortControllerRef = useRef(null); - const visionJobGenerationRef = useRef(0); - const avatarMotionWorkerRef = useRef(null); - const avatarRenderWorkerRef = useRef(null); - const avatarMotionCacheRef = useRef({ audioBlob: null, motion: null }); - const avatarTestImportedRef = useRef(false); - const avatarTestAudioImportedRef = useRef(false); + const { + assetDropPulseTimerRef, audioRef, audioSegmentRefs, audioUrlRef, autoRatioSourceKeyRef, + avatarMotionCacheRef, avatarMotionWorkerRef, avatarRenderWorkerRef, + avatarTestAudioImportedRef, avatarTestImportedRef, currentTimeRef, draggedAssetIdRef, + exportStartRef, fileInputRef, imageUrlRefs, musicRef, musicUrlRef, pointerAssetDragRef, + previewCanvasRef, previewShellRef, previewVideoRef, projectFileInputRef, sourceAudioRef, + sourceAudioUrlRef, suppressAssetClickRef, suppressTimelineClipClickRef, + timelineClipDragRef, timelineDurationRef, trackScrollRef, visionAbortControllerRef, + visionJobGenerationRef, visionObjectUrlsRef, visualPlaybackFrameRef, + visualPlaybackLastUpdateRef, visualPlaybackStartedAtRef, visualPlaybackStartTimeRef, + voiceRecorderChunksRef, voiceRecorderRef, voiceRecorderStartedAtRef, + voiceRecorderStreamRef, voiceRecorderTimerRef, + } = useEditorRefs(); + const { redo, undo } = useEditorHistory({ + audioSegments, captionPlacement, captionPosition, captionSegments, captionSize, + captionStyle, captionsEnabled, currentTime, fitMode, imageClipCount, imageDuration, + imageMeta, imageName, imageSrc, imageUrlRefs, musicBlob, musicDuration, musicName, + musicPeaks, musicUrl, musicUrlRef, musicVolume, notify, selectedAudioSegmentId, + selectedFilterId, selectedSegmentId, selectedStickerId, selectedStickerSegmentId, + selectedTrack, selectedTransitionId, selectedVisualSegmentId, script, setAudioSegments, + setCaptionPlacement, setCaptionPosition, setCaptionSegments, setCaptionSize, + setCaptionStyle, setCaptionsEnabled, setCurrentTime, setFitMode, setImageClipCount, + setImageDuration, setImageMeta, setImageName, setImageSrc, setIsPlaying, setMusicBlob, + setMusicDuration, setMusicName, setMusicPeaks, setMusicUrl, setMusicVolume, setScript, + setSelectedAudioSegmentId, setSelectedFilterId, setSelectedSegmentId, + setSelectedStickerId, setSelectedStickerSegmentId, setSelectedTrack, + setSelectedTransitionId, setSelectedVisualSegmentId, setSourceAudioBlob, + setSourceAudioDuration, setSourceAudioName, setSourceAudioPeaks, setSourceAudioStart, + setSourceAudioUrl, setSourceAudioVolume, setStickerSegments, setTimelineHorizon, + setTrackLocks, setTrackVisibility, setUserAssets, setVisualSegments, setVisualType, + sourceAudioBlob, sourceAudioDuration, sourceAudioName, sourceAudioPeaks, + sourceAudioStart, sourceAudioUrl, sourceAudioUrlRef, sourceAudioVolume, stickerSegments, + timelineHorizon, trackLocks, trackVisibility, userAssets, visualSegments, visualType, + }); const activeLanguage = uiLanguage || "zh"; const t = useMemo(() => createTranslator(activeLanguage), [activeLanguage]); - useEffect(() => { - document.documentElement.lang = activeLanguage === "zh" ? "zh-CN" : activeLanguage; - }, [activeLanguage]); - useEffect(() => () => { - avatarMotionWorkerRef.current?.terminate(); - avatarRenderWorkerRef.current?.terminate(); - }, []); const trOption = (name, option) => { if (option?.kind === "stickerCategory") { return activeLanguage !== "zh" && option.nameEn ? option.nameEn : name; @@ -441,4360 +162,328 @@ export function App() { }; const shouldShowLanguageIntro = !uiLanguage; - const selectedVoice = useMemo( - () => VOICES.find((voice) => voice.id === selectedVoiceId) ?? VOICES[0], - [selectedVoiceId], - ); - const selectedAudioSegment = - audioSegments.find((segment) => segment.id === selectedAudioSegmentId) ?? audioSegments.at(-1) ?? null; - const audioBlob = selectedAudioSegment?.blob ?? null; - const audioUrl = selectedAudioSegment?.url ?? ""; - const audioDuration = selectedAudioSegment?.duration ?? 0; - const peaks = selectedAudioSegment?.peaks ?? []; - - function openAvatarPanel() { - setAvatarPanelOpen(true); - if (!avatarMotionWorkerRef.current) { - avatarMotionWorkerRef.current = new Worker(new URL("./workers/joyvasa.worker.js", import.meta.url), { type: "module" }); - } - if (!avatarRenderWorkerRef.current) { - avatarRenderWorkerRef.current = new Worker(new URL("./workers/liveportrait.worker.js", import.meta.url), { type: "module" }); - } - } - - async function generateAvatarAcceptanceFrame(quality = "preview") { - if (avatarJob.running) return; - if (!previewVisualSrc || previewVisualType !== "image") { - notify(t("avatarNeedsPortrait")); - return; - } - if (!audioBlob) { - notify(t("avatarNeedsAudio")); - return; - } - - setAvatarJob({ running: true, progress: 1, phase: t("avatarPreparing") }); - try { - const sourceBlob = previewVisualSegment?.blob instanceof Blob - ? previewVisualSegment.blob - : await fetch(previewVisualSrc).then((response) => { - if (!response.ok) throw new Error(`读取肖像失败(HTTP ${response.status})`); - return response.blob(); - }); - const testDuration = import.meta.env.DEV ? Number(import.meta.env.VITE_AVATAR_TEST_DURATION || 0) : 0; - const sourceDuration = testDuration > 0 - ? Math.max(0.5, Math.min(4, testDuration)) - : Math.max(MIN_VISUAL_SEGMENT_SECONDS, Math.min(4, audioDuration || imageDuration || 4)); - let motionBuffer; - if (avatarMotionCacheRef.current.audioBlob === audioBlob && avatarMotionCacheRef.current.motion) { - motionBuffer = avatarMotionCacheRef.current.motion.slice(0); - setAvatarJob({ running: true, progress: 65, phase: t("avatarProgressReuseMotion") }); - } else { - // Never allocate JoyVASA while a previous LivePortrait graph remains - // resident. Some WebGPU drivers return corrupted texture-like frames - // under that memory pressure without reporting an inference error. - if (avatarRenderWorkerRef.current) { - await runAvatarWorkerTask( - avatarRenderWorkerRef.current, - { type: "releaseGpuSessions" }, - [], - "gpuReleased", - ); - } - const audioSamples = await decodeAvatarAudio16k(audioBlob); - if (!avatarMotionWorkerRef.current) { - avatarMotionWorkerRef.current = new Worker(new URL("./workers/joyvasa.worker.js", import.meta.url), { type: "module" }); - } - const motionResult = await runAvatarWorkerTask( - avatarMotionWorkerRef.current, - { type: "generate", audioSamples: audioSamples.buffer, modelBaseUrl: JOYVASA_PROJECT_MODEL_BASE_URL }, - [audioSamples.buffer], - "motion", - (progress) => setAvatarJob({ running: true, progress: progress.progress, phase: formatAvatarProgress(t, progress) }), - ); - avatarMotionCacheRef.current = { audioBlob, motion: motionResult.motion.slice(0) }; - motionBuffer = motionResult.motion; - // Keep downloaded JoyVASA bytes warm, but release its GPU weights before - // the portrait renderer is created. - await runAvatarWorkerTask( - avatarMotionWorkerRef.current, - { type: "release", modelBaseUrl: JOYVASA_PROJECT_MODEL_BASE_URL }, - [], - "released", - ); - } - if (!avatarRenderWorkerRef.current) { - avatarRenderWorkerRef.current = new Worker(new URL("./workers/liveportrait.worker.js", import.meta.url), { type: "module" }); - } - const renderResult = await runAvatarWorkerTask( - avatarRenderWorkerRef.current, - { - type: "generateVideo", portraitBlob: sourceBlob, motionBuffer, - modelBaseUrl: import.meta.env.VITE_LIVE_PORTRAIT_MODEL_BASE_URL || "", - joyVasaModelBaseUrl: JOYVASA_PROJECT_MODEL_BASE_URL, - webGpuModelBaseUrl: LIVE_PORTRAIT_WEBGPU_PROJECT_MODEL_BASE_URL, - quality, - renderFps: Math.max(1, Number(import.meta.env.VITE_AVATAR_RENDER_FPS || 8)), - neuralFps: Math.max(1, Number(import.meta.env.VITE_AVATAR_NEURAL_FPS || 2)), - duration: sourceDuration, - portraitKey: previewVisualSegment?.id || previewVisualSrc, - }, - [motionBuffer], - "videoFrames", - (progress) => setAvatarJob({ running: true, progress: progress.progress, phase: formatAvatarProgress(t, progress) }), - ); - setAvatarJob({ running: true, progress: 99, phase: t("avatarProgressEncodeVideo") }); - const blob = await encodeAvatarFrames( - renderResult.blobs, - renderResult.width, - renderResult.height, - renderResult.fps, - renderResult.keyframeTimes, - renderResult.duration, - ); - const url = URL.createObjectURL(blob); - imageUrlRefs.current.add(url); - const asset = { - id: crypto.randomUUID(), type: "video", src: url, name: "liveportrait-joyvasa.webm", - meta: `${renderResult.width} x ${renderResult.height} · JoyVASA + LivePortrait FP16 WebGPU`, blob, - duration: sourceDuration, width: renderResult.width, height: renderResult.height, trackFrames: [], - }; - setUserAssets((assets) => [asset, ...assets]); - replaceVisualTimeline(asset, sourceDuration); - setCurrentTime(0); - setAvatarJob({ running: false, progress: 100, phase: t("avatarAcceptanceDone") }); - notify(t("avatarTrackReplaced")); - } catch (error) { - setAvatarJob({ running: false, progress: 0, phase: "" }); - notify(`${t("avatarGenerationFailed")}:${error instanceof Error ? error.message : String(error)}`); - } - } - - const ratio = useMemo( - () => RATIO_OPTIONS.find((option) => option.id === ratioId) ?? RATIO_OPTIONS[0], - [ratioId], - ); - - const selectedFilter = useMemo( - () => VISUAL_STYLE_OPTIONS.find((filter) => filter.id === selectedFilterId) ?? FILTER_OPTIONS[0], - [selectedFilterId], - ); - - const selectedSticker = useMemo( - () => STICKERS.find((sticker) => sticker.id === selectedStickerId) ?? STICKERS[0], - [selectedStickerId], - ); - - const getStickerDragAsset = (sticker) => - sticker?.id && sticker.id !== "none" - ? { - ...sticker, - type: "sticker", - meta: "贴纸", - duration: DEFAULT_STICKER_SEGMENT_SECONDS, - } - : null; - - const segments = useMemo(() => captionSegments.map((segment) => segment.text), [captionSegments]); - const voiceTrackDuration = useMemo(() => getTimedSegmentsEnd(audioSegments), [audioSegments]); - const captionTargetDuration = voiceTrackDuration; - const captionTimeline = useMemo( - () => getCaptionTimeline(captionSegments, captionTargetDuration), - [captionSegments, captionTargetDuration], - ); - const captionDuration = captionTimeline.at(-1)?.end ?? 0; - const visualTimeline = useMemo(() => getVisualSegmentTimeline(visualSegments), [visualSegments]); - const stickerDuration = useMemo(() => getTimedSegmentsEnd(stickerSegments), [stickerSegments]); - const estimatedDuration = useMemo( - () => - Math.max( - voiceTrackDuration, - captionDuration, - sourceAudioBlob ? sourceAudioStart + sourceAudioDuration : 0, - musicBlob ? musicDuration : 0, - stickerDuration, - estimateDuration(script), - imageSrc ? imageDuration : 0, - ), - [ - voiceTrackDuration, - captionDuration, - imageDuration, - imageSrc, - musicBlob, - musicDuration, - script, - sourceAudioBlob, - sourceAudioDuration, - sourceAudioStart, - stickerDuration, - ], - ); - const timelineDuration = useMemo( - () => - Math.min( - MAX_TIMELINE_DURATION_SECONDS, - Math.max(timelineHorizon, DEFAULT_TIMELINE_DURATION_SECONDS, Math.ceil((estimatedDuration + 1) / 10) * 10), - ), - [estimatedDuration, timelineHorizon], - ); - timelineDurationRef.current = timelineDuration; - const currentSegmentIndex = getSegmentIndexAtTime( - captionSegments, - currentTime, - captionTargetDuration, - ); - const selectedSegmentIndex = Math.max( - 0, - captionSegments.findIndex((segment) => segment.id === selectedSegmentId), - ); - const focusedSegmentIndex = - currentSegmentIndex >= 0 ? currentSegmentIndex : Math.max(0, selectedSegmentIndex); - const currentCaptionSegment = - currentSegmentIndex >= 0 ? captionSegments[currentSegmentIndex] ?? null : null; - const selectedCaptionSegment = - captionSegments.find((segment) => segment.id === selectedSegmentId) ?? - currentCaptionSegment; - const currentCaption = currentCaptionSegment && !currentCaptionSegment.hidden ? currentCaptionSegment.text : ""; - const currentStickerSegmentIndex = getTimedSegmentIndexAtTime(stickerSegments, currentTime); - const currentStickerSegment = - currentStickerSegmentIndex >= 0 ? stickerSegments[currentStickerSegmentIndex] ?? null : null; - const selectedStickerSegmentIndex = Math.max( - 0, - stickerSegments.findIndex((segment) => segment.id === selectedStickerSegmentId), - ); - const previewSticker = - trackVisibility.sticker && currentStickerSegment - ? currentStickerSegment - : stickerSegments.length - ? STICKERS[0] - : selectedSticker; - const currentVisualSegmentIndex = getVisualSegmentIndexAtTime(visualSegments, currentTime); - const currentVisualSegment = - currentVisualSegmentIndex >= 0 ? visualSegments[currentVisualSegmentIndex] ?? null : null; - const currentVisualRange = - currentVisualSegmentIndex >= 0 ? visualTimeline[currentVisualSegmentIndex] ?? null : null; - const previewVisualSegmentIndex = - currentVisualSegmentIndex >= 0 - ? currentVisualSegmentIndex - : visualSegments.length - ? currentTime >= imageDuration - ? visualSegments.length - 1 - : 0 - : -1; - const previewVisualSegment = - previewVisualSegmentIndex >= 0 ? visualSegments[previewVisualSegmentIndex] ?? null : null; - const previewVisualRange = - previewVisualSegmentIndex >= 0 ? visualTimeline[previewVisualSegmentIndex] ?? null : null; - const previewVisualSrc = previewVisualSegment?.src || imageSrc; - const previewVisualType = previewVisualSegment?.type || visualType; - const activePreviewFilter = useMemo( - () => VISUAL_STYLE_OPTIONS.find((filter) => filter.id === (previewVisualSegment?.filterId || selectedFilterId)) ?? FILTER_OPTIONS[0], - [previewVisualSegment?.filterId, selectedFilterId], - ); - const previewVisualLocalTime = previewVisualRange - ? Math.max(0, currentTime - previewVisualRange.start) - : currentTime; - const previewVisualSourceTime = - previewVisualType === "video" - ? Math.max(0, Number(previewVisualSegment?.sourceStart) || 0) + previewVisualLocalTime - : previewVisualLocalTime; - const previewVisionKey = getVisionKey( - previewVisualSegment ?? - (previewVisualSrc - ? { - id: "visual-fallback", - src: previewVisualSrc, - type: previewVisualType, - width: previewVisualSegment?.width ?? 0, - height: previewVisualSegment?.height ?? 0, - } - : null), - ); - const previewVisionRecord = previewVisionKey ? visionRecords[previewVisionKey] ?? null : null; - const previewVisionBaseAnalysis = previewVisionRecord?.analysis ?? null; - const previewVisionAnalysis = useMemo( - () => - resolveVisionAnalysisAtTime( - previewVisionBaseAnalysis, - previewVisualType === "video" ? previewVideoMediaTime : previewVisualSourceTime, - ), - [ - previewVideoMediaTime, - previewVisionBaseAnalysis, - previewVisualSourceTime, - previewVisualType, - ], - ); - const previewVisionOptions = previewVisionRecord?.options ?? EMPTY_VISION_OPTIONS; - const selectedVisualSegmentIndex = Math.max( - 0, - visualSegments.findIndex((segment) => segment.id === selectedVisualSegmentId), - ); - const hasPlayableVisualTimeline = Boolean( - previewVisualSrc && trackVisibility.image && imageDuration > 0, - ); - const hasPlayableAudioTimeline = Boolean( - (trackVisibility.audio && audioBlob && audioUrl) || - (trackVisibility.source && sourceAudioBlob && sourceAudioUrl) || - (trackVisibility.music && musicBlob && musicUrl), - ); - const canPreview = hasPlayableVisualTimeline || hasPlayableAudioTimeline; - const previewVisionFrameSize = useMemo( - () => ({ - width: previewFrameSize.width || ratio.width, - height: previewFrameSize.height || ratio.height, - }), - [previewFrameSize.height, previewFrameSize.width, ratio.height, ratio.width], - ); - const previewSmartCropRect = useMemo(() => { - if ( - !previewVisionOptions.smartCrop || - !previewVisionAnalysis?.subject?.box || - !previewVisionAnalysis?.sourceSize - ) { - return null; - } - - return getSmartCropRect( - previewVisionAnalysis.sourceSize, - previewVisionFrameSize, - previewVisionAnalysis.subject.box, - { padding: 0.14 }, - ); - }, [ - previewVisionAnalysis, - previewVisionFrameSize, - previewVisionOptions.smartCrop, - ]); - const previewVisionOverlayBoxes = useMemo(() => { - if (!previewVisionOptions.showDetections || !previewVisionAnalysis?.sourceSize) { - return []; - } - - return (previewVisionAnalysis.detections ?? []) - .map((detection) => { - const mapped = mapNormalizedBoxToFrame( - detection.box, - previewVisionAnalysis.sourceSize, - previewVisionFrameSize, - { - fitMode, - smartCrop: previewSmartCropRect || false, - outputSize: previewVisionFrameSize, - }, - ); - if (!mapped?.normalized || mapped.width < 1 || mapped.height < 1) { - return null; - } - - return { - ...mapped.normalized, - label: detection.label, - score: detection.score, - isSubject: isSameVisionDetection(detection, previewVisionAnalysis.subject), - }; - }) - .filter(Boolean); - }, [ - fitMode, - previewSmartCropRect, - previewVisionAnalysis, - previewVisionFrameSize, - previewVisionOptions.showDetections, - ]); - const previewCaptionLayout = useMemo( - () => - getCaptionTextLayout({ - text: currentCaption, - captionSize, - captionStyle, - referenceFrame: previewVisionFrameSize, - renderFrame: previewVisionFrameSize, - }), - [captionSize, captionStyle, currentCaption, previewVisionFrameSize], - ); - const effectiveCaptionPlacement = useMemo(() => { - if ( - !previewVisionOptions.avoidCaptions || - !previewVisionAnalysis?.subject?.box || - !previewVisionAnalysis?.sourceSize - ) { - return captionPlacement; - } - - return getCaptionAvoidancePlacement(previewVisionAnalysis.subject.box, { - sourceSize: previewVisionAnalysis.sourceSize, - frameSize: previewVisionFrameSize, - fitMode, - smartCrop: previewSmartCropRect || false, - basePlacement: captionPlacement, - previousPlacement: captionPlacement, - captionSize: { - width: previewCaptionLayout.width / Math.max(1, previewVisionFrameSize.width), - height: previewCaptionLayout.height, - }, - safeMargin: 0.045, - }); - }, [ - captionPlacement, - fitMode, - previewCaptionLayout, - previewSmartCropRect, - previewVisionAnalysis, - previewVisionFrameSize, - previewVisionOptions.avoidCaptions, - ]); - const previewVisualRenderSrc = - previewVisionOptions.removeBackground && - previewVisualType === "image" && - previewVisionAnalysis?.cutoutUrl - ? previewVisionAnalysis.cutoutUrl - : previewVisualSrc; - const previewVisionMaskUrl = - previewVisionOptions.removeBackground && previewVisualType === "video" - ? previewVisionAnalysis?.cutoutUrl ?? "" - : ""; - const previewVisualObjectFit = previewSmartCropRect ? "cover" : fitMode; - const previewVisualObjectPosition = previewSmartCropRect - ? getObjectPositionForCrop(previewSmartCropRect) - : "50% 50%"; - - const filteredVoices = useMemo(() => { - return VOICES.filter((voice) => { - if (voiceFilter === "all") return true; - if (voiceFilter === "中文") return voice.language === "中文"; - if (voiceFilter === "English") return voice.language === "English"; - return voice.engine === voiceFilter; - }); - }, [voiceFilter]); - - const builtInAssets = useMemo( - () => [ - { - id: "sample", - type: "image", - src: SAMPLE_IMAGE, - name: "sample-portrait.png", - meta: "1920 x 1080", - width: 1920, - height: 1080, - }, - { - id: "sample-motion", - type: "video", - src: "/assets/sample-motion.mp4", - name: "sample-motion.mp4", - meta: "640 x 360 · 00:02.50", - duration: 2.5, - width: 640, - height: 360, - trackFrames: [], - }, - ], - [], - ); - - useEffect(() => { - setFitMode("contain"); - }, [ratioId]); - - useEffect(() => { - const testImageUrl = import.meta.env.DEV ? import.meta.env.VITE_AVATAR_TEST_IMAGE_URL : ""; - if (!testImageUrl || avatarTestImportedRef.current) return; - avatarTestImportedRef.current = true; - fetch(testImageUrl) - .then((response) => { - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.blob(); - }) - .then((blob) => { - const url = URL.createObjectURL(blob); - imageUrlRefs.current.add(url); - const asset = { - id: crypto.randomUUID(), - type: "image", - src: url, - name: "老外戴眼镜中年人物肖像生成-modnet.png", - meta: "819 x 1024 · E2E test", - blob, - duration: 4, - width: 819, - height: 1024, - trackFrames: [], - }; - setUserAssets((assets) => [asset, ...assets]); - replaceVisualTimeline(asset, 4); - notify("端到端测试肖像已载入画面轨"); - }) - .catch((error) => { - avatarTestImportedRef.current = false; - console.error("Avatar E2E test image import failed", error); - }); - }, []); - - useEffect(() => { - if (!import.meta.env.DEV || !import.meta.env.VITE_AVATAR_TEST_IMAGE_URL || avatarTestAudioImportedRef.current) return; - avatarTestAudioImportedRef.current = true; - fetch("/assets/avatar-e2e-16k.wav") - .then((response) => { - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.blob(); - }) - .then(async (blob) => { - const waveform = await decodeWaveform(blob); - replaceAudio(blob, waveform.duration, waveform.peaks, "端到端测试配音已载入"); - notify("端到端测试配音已载入配音轨"); - }) - .catch((error) => { - avatarTestAudioImportedRef.current = false; - console.error("Avatar E2E test audio import failed", error); - }); - }, []); - - useEffect(() => { - const ratioSource = visualSegments.find((segment) => segment.width > 0 && segment.height > 0); - if (!ratioSource) { - autoRatioSourceKeyRef.current = ""; - return; - } - - const sourceKey = `${ratioSource.assetId || ratioSource.id}:${ratioSource.width}x${ratioSource.height}`; - if (autoRatioSourceKeyRef.current === sourceKey) { - return; - } - autoRatioSourceKeyRef.current = sourceKey; - - const nextRatioId = getNearestRatioIdForSize(ratioSource.width, ratioSource.height); - if (!nextRatioId || nextRatioId === ratioId) { - return; - } - - const nextRatio = RATIO_OPTIONS.find((option) => option.id === nextRatioId); - setRatioId(nextRatioId); - notify(`已根据素材自动切换为 ${nextRatio?.label ?? nextRatioId}`); - }, [ratioId, visualSegments]); - - useEffect(() => { - audioSegments.forEach((segment) => { - const audio = audioSegmentRefs.current.get(segment.id); - if (!audio) return; - audio.volume = getAudioSegmentPreviewVolume(segment, currentTime); - }); - }, [audioSegments, currentTime]); - - useEffect(() => { - if (!isPlaying || !trackVisibility.audio) return; - audioSegments.forEach((segment) => { - const audio = audioSegmentRefs.current.get(segment.id); - if (!audio) return; - const active = isTimelineTimeInsideTrack(currentTime, segment.start, segment.duration); - if (active) { - const expected = getTimelineTrackLocalTime(currentTime, segment.start, segment.duration); - if (Math.abs(audio.currentTime - expected) > 0.2) audio.currentTime = expected; - if (audio.paused) audio.play().catch(() => {}); - } else if (!audio.paused) { - audio.pause(); - } - }); - }, [audioSegments, currentTime, isPlaying, trackVisibility.audio]); - - useEffect(() => { - if (sourceAudioRef.current) { - sourceAudioRef.current.volume = sourceAudioVolume; - } - }, [sourceAudioVolume, sourceAudioUrl]); - - useEffect(() => { - const sourceAudio = sourceAudioRef.current; - if (!sourceAudio || !sourceAudioUrl) { - return; - } - - const localTime = getTimelineTrackLocalTime(currentTime, sourceAudioStart, sourceAudioDuration); - if (Math.abs(sourceAudio.currentTime - localTime) > 0.22) { - sourceAudio.currentTime = localTime; - } - - const shouldPlaySource = - isPlaying && - trackVisibility.source && - isTimelineTimeInsideTrack(currentTime, sourceAudioStart, sourceAudioDuration); - - if (shouldPlaySource && sourceAudio.paused) { - sourceAudio.play().catch(() => {}); - return; - } - - if (!shouldPlaySource && !sourceAudio.paused) { - sourceAudio.pause(); - } - }, [ - currentTime, - isPlaying, - sourceAudioDuration, - sourceAudioStart, - sourceAudioUrl, - trackVisibility.source, - ]); - - useEffect(() => { - if (musicRef.current) { - musicRef.current.volume = musicVolume; - } - }, [musicVolume, musicUrl]); - - useEffect(() => { - currentTimeRef.current = currentTime; - }, [currentTime]); - - useEffect(() => { - const shell = previewShellRef.current; - if (!shell) { - return undefined; - } - - const updatePreviewFrameSize = () => { - const style = window.getComputedStyle(shell); - const availableWidth = Math.max( - 1, - shell.clientWidth - parseFloat(style.paddingLeft || "0") - parseFloat(style.paddingRight || "0"), - ); - const availableHeight = Math.max( - 1, - shell.clientHeight - parseFloat(style.paddingTop || "0") - parseFloat(style.paddingBottom || "0"), - ); - const ratioValue = ratio.width / ratio.height; - const widthFromHeight = availableHeight * ratioValue; - const nextWidth = Math.max(1, Math.floor(Math.min(availableWidth, widthFromHeight))); - const nextHeight = Math.max(1, Math.floor(nextWidth / ratioValue)); - - setPreviewFrameSize((size) => - size.width === nextWidth && size.height === nextHeight - ? size - : { width: nextWidth, height: nextHeight }, - ); - }; - - updatePreviewFrameSize(); - - if (window.ResizeObserver) { - const observer = new ResizeObserver(updatePreviewFrameSize); - observer.observe(shell); - return () => observer.disconnect(); - } - - window.addEventListener("resize", updatePreviewFrameSize); - return () => window.removeEventListener("resize", updatePreviewFrameSize); - }, [ratio.width, ratio.height, compactRail]); - - useEffect(() => { - if (!exporting) { - return undefined; - } - - const updateElapsed = () => { - const startedAt = exportStartRef.current || performance.now(); - setExportElapsedSeconds((performance.now() - startedAt) / 1000); - }; - updateElapsed(); - const timer = window.setInterval(updateElapsed, 250); - return () => window.clearInterval(timer); - }, [exporting]); - - useEffect(() => { - setPreviewVideoMediaTime( - previewVisualType === "video" - ? Math.max(0, Number(previewVisualSegment?.sourceStart) || 0) - : 0, - ); - }, [previewVisualSegment?.id, previewVisualSrc, previewVisualType]); - - useEffect(() => { - const video = previewVideoRef.current; - if (!video || previewVisualType !== "video") { - return; - } - - const maximumTime = Math.max(0, (Number(video.duration) || previewVisualSourceTime) - 0.001); - const boundedTime = Math.min(Math.max(0, previewVisualSourceTime), maximumTime); - if (Number.isFinite(boundedTime) && Math.abs(video.currentTime - boundedTime) > 0.2) { - video.currentTime = boundedTime; - setPreviewVideoMediaTime(boundedTime); - } - if (isPlaying && trackVisibility.image && video.paused) { - video.play().catch(() => {}); - } - }, [ - isPlaying, - previewVisualSourceTime, - previewVisualSrc, - previewVisualType, - trackVisibility.image, - ]); - - useEffect(() => { - const video = previewVideoRef.current; - if (!video || previewVisualType !== "video") { - return; - } - - if (!isPlaying || !trackVisibility.image) { - video.pause(); - return; - } - - video.play().catch(() => {}); - }, [isPlaying, previewVisualSrc, previewVisualType, trackVisibility.image]); - - useEffect(() => { - if (!isPlaying || estimatedDuration <= 0) { - return undefined; - } - - const startTime = - currentTimeRef.current >= estimatedDuration - 0.02 ? 0 : Math.max(0, currentTimeRef.current); - if (startTime !== currentTimeRef.current) { - setCurrentTime(startTime); - currentTimeRef.current = startTime; - } - - visualPlaybackStartTimeRef.current = startTime; - visualPlaybackStartedAtRef.current = performance.now(); - visualPlaybackLastUpdateRef.current = 0; - - const tick = (now) => { - const elapsedSeconds = (now - visualPlaybackStartedAtRef.current) / 1000; - const nextTime = Math.min( - estimatedDuration, - visualPlaybackStartTimeRef.current + elapsedSeconds, - ); - currentTimeRef.current = nextTime; - if (now - visualPlaybackLastUpdateRef.current > PLAYBACK_UI_FRAME_MS || nextTime >= estimatedDuration) { - visualPlaybackLastUpdateRef.current = now; - setCurrentTime(nextTime); - } - - if (nextTime >= estimatedDuration) { - pauseTimelineMedia(); - setIsPlaying(false); - visualPlaybackFrameRef.current = 0; - return; - } - - visualPlaybackFrameRef.current = window.requestAnimationFrame(tick); - }; - - visualPlaybackFrameRef.current = window.requestAnimationFrame(tick); - - return () => { - if (visualPlaybackFrameRef.current) { - window.cancelAnimationFrame(visualPlaybackFrameRef.current); - visualPlaybackFrameRef.current = 0; - } - }; - }, [estimatedDuration, isPlaying]); - - useEffect(() => { - setCurrentTime((time) => { - const clamped = Math.min(time, timelineDuration); - if (audioRef.current && clamped !== time) { - audioRef.current.currentTime = clamped; - } - if (sourceAudioRef.current && clamped !== time) { - sourceAudioRef.current.currentTime = getTimelineTrackLocalTime( - clamped, - sourceAudioStart, - sourceAudioDuration, - ); - } - if (musicRef.current && clamped !== time) { - musicRef.current.currentTime = clamped; - } - return clamped; - }); - }, [timelineDuration, sourceAudioDuration, sourceAudioStart]); - - useEffect(() => { - if (!captionSegments.length) { - setSelectedSegmentId(""); - return; - } - - if (!captionSegments.some((segment) => segment.id === selectedSegmentId)) { - setSelectedSegmentId(captionSegments[0].id); - } - }, [captionSegments, selectedSegmentId]); - - useEffect(() => { - if (!visualSegments.length) { - setSelectedVisualSegmentId(""); - return; - } - - if (!visualSegments.some((segment) => segment.id === selectedVisualSegmentId)) { - setSelectedVisualSegmentId(visualSegments[0].id); - } - }, [selectedVisualSegmentId, visualSegments]); - - useEffect(() => { - if (currentVisualSegment?.src) { - setCurrentVisualAsset(currentVisualSegment); - } - }, [currentVisualSegment]); - - useEffect(() => { - const handleKeyDown = (event) => { - const target = event.target; - const isTyping = - target instanceof HTMLElement && - (target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.tagName === "SELECT" || - target.isContentEditable); - - if ( - isTyping || - event.metaKey || - event.ctrlKey || - event.altKey || - (event.key !== "Delete" && event.key !== "Backspace") - ) { - return; - } - - const hasSelectedTimelineItem = - (selectedTrack === "caption" && selectedSegmentId && captionSegments.some((segment) => segment.id === selectedSegmentId)) || - (selectedTrack === "sticker" && selectedStickerSegmentId && stickerSegments.some((segment) => segment.id === selectedStickerSegmentId)) || - (selectedTrack === "image" && selectedVisualSegmentId && visualSegments.some((segment) => segment.id === selectedVisualSegmentId)) || - (selectedTrack === "audio" && selectedAudioSegmentId && audioSegments.some((segment) => segment.id === selectedAudioSegmentId)) || - (selectedTrack === "source" && sourceAudioBlob) || - (selectedTrack === "music" && musicBlob); - - if (hasSelectedTimelineItem) { - event.preventDefault(); - handleDeleteTrack(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + const { + activePreviewFilter, audioBlob, audioDuration, audioUrl, canPreview, captionDuration, + captionTargetDuration, captionTimeline, currentCaption, currentCaptionSegment, + currentSegmentIndex, currentStickerSegment, currentStickerSegmentIndex, + currentVisualRange, currentVisualSegment, currentVisualSegmentIndex, estimatedDuration, + focusedSegmentIndex, getStickerDragAsset, peaks, previewSticker, + previewVisionBaseAnalysis, previewVisionKey, previewVisionRecord, previewVisualLocalTime, + previewVisualRange, previewVisualSegment, previewVisualSegmentIndex, + previewVisualSourceTime, previewVisualSrc, previewVisualType, ratio, segments, + selectedAudioSegment, selectedCaptionSegment, selectedFilter, selectedSegmentIndex, + selectedSticker, selectedStickerSegmentIndex, selectedVisualSegmentIndex, selectedVoice, + stickerDuration, timelineDuration, visualTimeline, voiceTrackDuration, + } = useTimelineModel({ + audioSegments, captionSegments, currentTime, imageDuration, imageSrc, musicBlob, + musicDuration, musicUrl, ratioId, script, selectedAudioSegmentId, selectedFilterId, + selectedSegmentId, selectedStickerId, selectedStickerSegmentId, + selectedVisualSegmentId, selectedVoiceId, sourceAudioBlob, sourceAudioDuration, + sourceAudioStart, sourceAudioUrl, stickerSegments, timelineDurationRef, timelineHorizon, + trackVisibility, visionRecords, visualSegments, visualType, + }); + const previewFrameSize = usePreviewFrameSize(previewShellRef, ratio, compactRail); + const exportElapsedSeconds = useExportElapsed(exporting, exportStartRef); + const { + effectiveCaptionPlacement, previewSmartCropRect, previewVisionAnalysis, + previewVisionFrameSize, previewVisionMaskUrl, previewVisionOptions, + previewVisionOverlayBoxes, previewVisualObjectFit, previewVisualObjectPosition, + previewVisualRenderSrc, + } = usePreviewModel({ + captionPlacement, captionSize, captionStyle, currentCaption, fitMode, previewFrameSize, + previewVideoMediaTime, previewVisionBaseAnalysis, previewVisionRecord, + previewVisualSourceTime, previewVisualSrc, previewVisualType, ratio, }); - useEffect(() => { - const timer = window.setTimeout(() => setLastSaved(formatSavedTime()), 450); - return () => window.clearTimeout(timer); - }, [ - script, - imageSrc, - visualType, - imageDuration, - captionPlacement, - selectedVoiceId, - speed, - volume, - musicName, - musicDuration, - musicVolume, - sourceAudioName, - sourceAudioDuration, - sourceAudioStart, - sourceAudioVolume, - ratioId, - fitMode, - selectedFilterId, - selectedStickerId, - captionSegments, - visualSegments, - visionRecords, - timelineZoom, - ]); - - useEffect(() => { - return () => { - if (audioUrlRef.current) { - URL.revokeObjectURL(audioUrlRef.current); - } - if (sourceAudioUrlRef.current) { - URL.revokeObjectURL(sourceAudioUrlRef.current); - } - if (musicUrlRef.current) { - URL.revokeObjectURL(musicUrlRef.current); - } - imageUrlRefs.current.forEach((url) => URL.revokeObjectURL(url)); - imageUrlRefs.current.clear(); - visionAbortControllerRef.current?.abort(); - visionObjectUrlsRef.current.forEach((urls) => revokeVisionObjectUrls(urls)); - visionObjectUrlsRef.current.clear(); - disposeVisionWorker(); - voiceRecorderStreamRef.current?.getTracks().forEach((track) => track.stop()); - window.clearInterval(voiceRecorderTimerRef.current); - window.clearTimeout(toastTimerRef.current); - }; - }, []); - - function notify(message) { - setToast(message); - window.clearTimeout(toastTimerRef.current); - toastTimerRef.current = window.setTimeout(() => setToast(""), 2600); - } - - function clearAllVisionState() { - visionJobGenerationRef.current += 1; - visionAbortControllerRef.current?.abort(); - visionAbortControllerRef.current = null; - visionObjectUrlsRef.current.forEach((urls) => revokeVisionObjectUrls(urls)); - visionObjectUrlsRef.current.clear(); - setVisionRecords({}); - setVisionJob({ running: false, key: "", progress: 0, phase: "" }); - } - - function getProjectSnapshot() { - const serializableVisualSegments = visualSegments.map(({ blob, trackFrames, src, cutoutVisual, ...segment }) => segment); - return { - script, selectedVoiceId, speed, volume, ratioId, fitMode, captionPosition, - captionPlacement, captionSize, captionStyle, captionsEnabled, captionSegments, visualSegments: serializableVisualSegments, - stickerSegments, selectedFilterId, selectedTransitionId, selectedStickerId, trackVisibility, timelineZoom, - audioDuration, musicName, musicDuration, musicVolume, sourceAudioName, sourceAudioDuration, sourceAudioStart, sourceAudioVolume, - }; - } - - async function handleExportProject() { - setShowFileMenu(false); - try { - notify("正在打包工程与媒体素材…"); - const archive = await createProjectArchive({ - project: getProjectSnapshot(), - visualSegments, - audio: audioBlob ? { blob: audioBlob, name: "ai-voiceover" } : null, - sourceAudio: sourceAudioBlob ? { blob: sourceAudioBlob, name: sourceAudioName || "source-audio" } : null, - music: musicBlob ? { blob: musicBlob, name: musicName || "background-music" } : null, - }); - downloadBlob(archive, "AI-配音项目.timeline"); - notify("工程包已导出(含媒体素材)"); - } catch (error) { - notify(error instanceof Error ? `工程导出失败:${error.message}` : "工程导出失败"); - } - } - - function handleNewProject() { - if (!window.confirm("新建工程将清空当前时间线,是否继续?")) return; - setScript(DEFAULT_SCRIPT); - setCaptionSegments(createCaptionSegments(DEFAULT_SCRIPT)); - setSelectedSegmentId(""); - clearImageTrack(""); - clearAudioTrack(""); - clearSourceAudioTrack(""); - clearMusicTrack(""); - setStickerSegments([]); - setSelectedStickerSegmentId(""); - clearAllVisionState(); - setCurrentTime(0); - setShowFileMenu(false); - notify("已新建空白工程"); - } - - async function handleImportProject(file) { - if (!file) { - projectFileInputRef.current?.click(); - return; - } - try { - let archive; - try { - archive = await readProjectArchive(file); - } catch (archiveError) { - // Projects exported before the portable archive format were JSON-only. - // Keep them importable so upgrading the editor never strands a project. - const legacy = JSON.parse(await readProjectFileAsText(file)); - if (legacy?.format !== "timeline-studio-project" || !legacy.project) throw archiveError; - archive = { - payload: { ...legacy, media: { visuals: [] } }, - visualMedia: new Map(), - audio: null, - sourceAudio: null, - music: null, - legacy: true, - }; - } - const { payload, visualMedia, audio: importedAudio, sourceAudio: importedSourceAudio, music: importedMusic } = archive; - const data = payload.project; - setScript(typeof data.script === "string" ? data.script : DEFAULT_SCRIPT); - const nextCaptions = Array.isArray(data.captionSegments) ? data.captionSegments : createCaptionSegments(data.script || DEFAULT_SCRIPT); - setCaptionSegments(nextCaptions); - setSelectedSegmentId(nextCaptions[0]?.id ?? ""); - setSelectedVoiceId(data.selectedVoiceId || VOICES[0].id); - setSpeed(Number(data.speed) || 1); setVolume(Number(data.volume) || 1); - setRatioId(RATIO_OPTIONS.some((option) => option.id === data.ratioId) ? data.ratioId : "16:9"); - setFitMode(data.fitMode || "contain"); setCaptionPosition(data.captionPosition || "bottom"); - setCaptionPlacement(data.captionPlacement || { x: 50, y: 78 }); setCaptionSize(Number(data.captionSize) || 12); - setCaptionStyle(data.captionStyle || captionStyle); - setCaptionsEnabled(data.captionsEnabled !== false); setTrackVisibility(data.trackVisibility || trackVisibility); - setTimelineZoom(Number(data.timelineZoom) || 1); setSelectedFilterId(data.selectedFilterId || "none"); - setSelectedTransitionId(data.selectedTransitionId || "none"); setSelectedStickerId(data.selectedStickerId || "none"); - setStickerSegments(Array.isArray(data.stickerSegments) ? data.stickerSegments : []); - const importedVisuals = Array.isArray(data.visualSegments) ? data.visualSegments - .map((segment) => { - const media = visualMedia.get(segment.id); - if (media?.blob) return { ...segment, src: URL.createObjectURL(media.blob), blob: media.blob }; - // JSON-era projects could only retain public/static asset URLs. - return segment?.src ? segment : null; - }) - .filter(Boolean) : []; - importedVisuals.filter((segment) => segment.src?.startsWith("blob:")).forEach((segment) => imageUrlRefs.current.add(segment.src)); - setVisualSegments(importedVisuals); setImageDuration(getVisualSegmentsTotal(importedVisuals)); - setImageClipCount(getImageThumbnailCount(getVisualSegmentsTotal(importedVisuals))); - setCurrentVisualAsset(importedVisuals[0] || null); - if (importedAudio) { - const decoded = await decodeWaveform(importedAudio); - replaceAudio(importedAudio, Number(data.audioDuration) || decoded.duration, decoded.peaks, "已恢复工程配音"); - } else clearAudioTrack(""); - if (importedSourceAudio) { - const decoded = await decodeWaveform(importedSourceAudio); - replaceSourceAudio(importedSourceAudio, Number(data.sourceAudioDuration) || decoded.duration, decoded.peaks, data.sourceAudioName || "source-audio", "", Number(data.sourceAudioStart) || 0); - } else clearSourceAudioTrack(""); - if (importedMusic) { - const decoded = await decodeWaveform(importedMusic); - replaceMusic(importedMusic, Number(data.musicDuration) || decoded.duration, decoded.peaks, data.musicName || "background-music", ""); - } else clearMusicTrack(""); - setMusicVolume(Number(data.musicVolume) || 0.35); setSourceAudioVolume(Number(data.sourceAudioVolume) || 1); - setCurrentTime(0); clearAllVisionState(); setShowFileMenu(false); - notify(archive.legacy - ? "旧版工程已导入;请重新添加未嵌入的本地媒体,然后导出为 .timeline 工程包" - : "工程包已导入,媒体素材已恢复"); - } catch (error) { - const detail = error instanceof Error && error.message ? `:${error.message}` : ""; - notify(`无法读取工程文件${detail}`); - } - if (projectFileInputRef.current) projectFileInputRef.current.value = ""; - } - - async function analyzeCurrentVisual() { - if (!previewVisualSrc || !previewVisionKey) { - notify("请先把图片或视频放到图片轨"); - return; - } - - if (visionJob.running && visionJob.key === previewVisionKey) { - visionJobGenerationRef.current += 1; - visionAbortControllerRef.current?.abort(); - visionAbortControllerRef.current = null; - setVisionJob({ running: false, key: previewVisionKey, progress: 0, phase: "分析已取消" }); - notify("已取消当前视觉分析"); - return; - } - - visionAbortControllerRef.current?.abort(); - const controller = new AbortController(); - visionAbortControllerRef.current = controller; - const jobGeneration = visionJobGenerationRef.current + 1; - visionJobGenerationRef.current = jobGeneration; - const analysisKey = previewVisionKey; - const analysisType = previewVisualType; - setVisionJob({ - running: true, - key: analysisKey, - progress: 1, - phase: analysisType === "video" ? "准备分析整段视频" : "截取当前视觉画面", - }); - - try { - const handleVisionProgress = ({ progress: nextProgress, phase }) => { - setVisionJob((job) => - job.key === analysisKey - ? { - ...job, - progress: Math.max(job.progress, nextProgress), - phase: phase || job.phase, - } - : job, - ); - }; - const source = previewVisualSegment?.blob || previewVisualSrc; - let analysis; - let nextObjectUrls = []; - - if (analysisType === "video") { - const videoDuration = Math.max( - 0.05, - Number(previewVideoRef.current?.duration) || - Number(previewVisualSegment?.duration) || - 0.05, - ); - const result = await analyzeVideoVisualTrack({ - src: source, - duration: videoDuration, - includeMatting: true, - fps: 2, - maxSamples: 180, - maxDimension: 512, - threshold: 0.32, - preferredLabels: ["person", "cat", "dog", "car", "bottle", "chair"], - signal: controller.signal, - onProgress: handleVisionProgress, - }); - const samples = result.samples.map((sample) => { - const { cutoutBlob, ...sampleData } = sample; - const cutoutUrl = cutoutBlob ? URL.createObjectURL(cutoutBlob) : ""; - if (cutoutUrl) { - nextObjectUrls.push(cutoutUrl); - } - return { ...sampleData, cutoutUrl }; - }); - analysis = { - ...result, - samples, - analyzedAt: Date.now(), - visualType: analysisType, - }; - } else { - const frameBlob = await captureVisualFrame({ - src: source, - type: analysisType, - maxDimension: 1024, - outputType: "image/png", - quality: 0.92, - signal: controller.signal, - }); - const result = await analyzeVisualSubject({ - blob: frameBlob, - includeMatting: true, - threshold: 0.32, - preferredLabels: ["person", "cat", "dog", "car", "bottle", "chair"], - signal: controller.signal, - onProgress: handleVisionProgress, - }); - const cutoutUrl = result.cutoutBlob ? URL.createObjectURL(result.cutoutBlob) : ""; - if (cutoutUrl) { - nextObjectUrls = [cutoutUrl]; - } - analysis = { - ...result, - cutoutUrl, - analyzedAt: Date.now(), - visualType: analysisType, - }; - } - - if (controller.signal.aborted || jobGeneration !== visionJobGenerationRef.current) { - return; - } - - const previousUrls = visionObjectUrlsRef.current.get(analysisKey); - revokeVisionObjectUrls(previousUrls); - visionObjectUrlsRef.current.set(analysisKey, nextObjectUrls); - - setVisionRecords((records) => { - const previous = records[analysisKey]; - return { - ...records, - [analysisKey]: { - analysis, - options: previous?.options ?? { - ...DEFAULT_VISION_OPTIONS, - removeBackground: false, - }, - }, - }; - }); - setVisionJob({ - running: false, - key: analysisKey, - progress: 100, - phase: "视觉主体分析完成", - }); - notify( - analysisType === "image" - ? "YOLOS tiny 主体识别与 MODNet 抠图已就绪" - : `全视频分析完成:YOLOS + MODNet 已覆盖 ${analysis.samples.length} 个时序帧`, - ); - } catch (error) { - if (error?.name === "AbortError") { - return; - } - console.error(error); - setVisionJob({ - running: false, - key: analysisKey, - progress: 0, - phase: "视觉分析失败", - }); - notify(error?.message || "视觉主体分析失败,请重试"); - } finally { - if (visionAbortControllerRef.current === controller) { - visionAbortControllerRef.current = null; - setVisionJob((job) => (job.running && job.key === analysisKey ? { ...job, running: false } : job)); - } - } - } - - function toggleVisionOption(optionId) { - if (!previewVisionKey || !previewVisionRecord) { - return; - } - - const hasMatting = - Boolean(previewVisionAnalysis?.cutoutUrl) || - Boolean(previewVisionBaseAnalysis?.samples?.some((sample) => sample.cutoutUrl)); - if (optionId === "removeBackground" && !hasMatting) { - notify("请先完成当前图片或整段视频的 MODNet 分析"); - return; - } - - const nextEnabled = !previewVisionOptions[optionId]; - setVisionRecords((records) => { - const record = records[previewVisionKey]; - if (!record) { - return records; - } - return { - ...records, - [previewVisionKey]: { - ...record, - options: { - ...record.options, - [optionId]: nextEnabled, - }, - }, - }; - }); - - const optionLabels = { - showDetections: "主体识别框", - removeBackground: "MODNet 抠图", - avoidCaptions: "字幕智能避让", - smartCrop: "主体智能裁切", - }; - notify(`${optionLabels[optionId] ?? "智能画面"}已${nextEnabled ? "开启" : "关闭"}`); - } - - function setFitModeFromUser(nextModeOrUpdater) { - setFitMode(nextModeOrUpdater); - if (!previewVisionKey || !previewVisionRecord?.options?.smartCrop) { - return; - } - setVisionRecords((records) => { - const record = records[previewVisionKey]; - return record - ? { - ...records, - [previewVisionKey]: { - ...record, - options: { ...record.options, smartCrop: false }, - }, - } - : records; - }); - } - - function clearVisionAnalysis() { - if (!previewVisionKey) { - return; - } - - if (visionJob.running && visionJob.key === previewVisionKey) { - visionJobGenerationRef.current += 1; - visionAbortControllerRef.current?.abort(); - visionAbortControllerRef.current = null; - } - const cutoutUrls = visionObjectUrlsRef.current.get(previewVisionKey); - if (cutoutUrls) { - revokeVisionObjectUrls(cutoutUrls); - visionObjectUrlsRef.current.delete(previewVisionKey); - } - setVisionRecords((records) => { - const nextRecords = { ...records }; - delete nextRecords[previewVisionKey]; - return nextRecords; - }); - setVisionJob({ running: false, key: "", progress: 0, phase: "" }); - notify("当前素材的视觉分析已清除"); - } - - function downloadVisionCutout() { - if (previewVisualType === "video") { - notify("视频 MODNet 遮罩会随时间变化,并随视频一起预览和导出"); - return; - } - const cutoutBlob = previewVisionAnalysis?.cutoutBlob; - if (!cutoutBlob) { - notify("当前素材还没有透明抠图"); - return; - } - const baseName = (previewVisualSegment?.name || imageName || "subject") - .replace(/\.[^.]+$/, "") - .replace(/[^\w\u3400-\u9fff-]+/g, "-"); - downloadBlob(cutoutBlob, `${baseName || "subject"}-modnet.png`); - notify("透明 PNG 已下载"); - } - - function removeVisionRecordsForAsset(asset) { - if (!asset?.id && !asset?.src) { - return; - } - - const belongsToAsset = (key) => - Boolean( - (asset.id && key.startsWith(`${asset.id}::`)) || - (asset.src && key.includes(`::${asset.src}`)), - ); - if (visionJob.running && belongsToAsset(visionJob.key)) { - visionJobGenerationRef.current += 1; - visionAbortControllerRef.current?.abort(); - visionAbortControllerRef.current = null; - setVisionJob({ running: false, key: "", progress: 0, phase: "" }); - } - - setVisionRecords((records) => { - const nextRecords = { ...records }; - Object.keys(records).forEach((key) => { - if (!belongsToAsset(key)) { - return; - } - const cutoutUrls = visionObjectUrlsRef.current.get(key); - if (cutoutUrls) { - revokeVisionObjectUrls(cutoutUrls); - visionObjectUrlsRef.current.delete(key); - } - delete nextRecords[key]; - }); - return nextRecords; - }); - } - - function selectTool(toolId) { - setActiveTool(toolId); - if (toolId !== "smart") setAvatarPanelOpen(false); - if (toolId === "audio") { - setSelectedTrack("audio"); - setVoiceTab("synthesis"); - } - if (toolId === "caption") { - setSelectedTrack("caption"); - } - } - - function chooseInterfaceLanguage(languageId) { - saveLanguagePreference(languageId); - setIntroClosing(true); - window.setTimeout(() => { - setUiLanguage(languageId); - setIntroClosing(false); - }, 520); - } - - function pushScriptHistory(previousScript) { - setUndoStack((stack) => { - if (stack.at(-1) === previousScript) { - return stack; - } - return [...stack.slice(-30), previousScript]; - }); - setRedoStack([]); - } - - function updateScript(nextScript) { - pushScriptHistory(script); - setScript(nextScript); - if (!audioSegments.length) { - const nextSegments = createCaptionSegments(nextScript); - setCaptionSegments(nextSegments); - setSelectedSegmentId(nextSegments[0]?.id ?? ""); - } - } - - function updateCaptionSegmentText(segmentId, text) { - if (trackLocks.caption) { - notify("字幕轨已锁定,无法编辑"); - return; - } - - setCaptionSegments((items) => { - const nextSegments = items.map((segment) => - segment.id === segmentId ? { ...segment, text } : segment, - ); - const nextScript = getCaptionScript(nextSegments); - setScript(nextScript); - return nextSegments; - }); - } - - function toggleCaptionSegmentHidden(segmentId) { - if (trackLocks.caption) { - notify("字幕轨已锁定,无法隐藏"); - return; - } - - setCaptionSegments((items) => - items.map((segment) => - segment.id === segmentId ? { ...segment, hidden: !segment.hidden } : segment, - ), - ); - notify("字幕显示状态已更新"); - } - - function handleCaptionPositionChange(position) { - const placementMap = { - top: { x: 50, y: 18 }, - middle: { x: 50, y: 50 }, - bottom: { x: 50, y: 78 }, - }; - setCaptionPosition(position); - setCaptionPlacement(placementMap[position] ?? placementMap.bottom); - if (previewVisionKey && previewVisionRecord?.options?.avoidCaptions) { - setVisionRecords((records) => { - const record = records[previewVisionKey]; - return record - ? { - ...records, - [previewVisionKey]: { - ...record, - options: { ...record.options, avoidCaptions: false }, - }, - } - : records; - }); - notify("已切回手动字幕位置,智能避让已关闭"); - } - } - - function startCaptionDrag(event) { - if (event.button !== 0) { - return; - } - - if (trackLocks.caption) { - notify("字幕轨已锁定,无法拖动"); - return; - } - - event.stopPropagation(); - const disabledSmartAvoidance = Boolean( - previewVisionKey && previewVisionRecord?.options?.avoidCaptions, - ); - if (disabledSmartAvoidance) { - setVisionRecords((records) => { - const record = records[previewVisionKey]; - return record - ? { - ...records, - [previewVisionKey]: { - ...record, - options: { ...record.options, avoidCaptions: false }, - }, - } - : records; - }); - } - setSelectedTrack("caption"); - if (currentCaptionSegment) { - setSelectedSegmentId(currentCaptionSegment.id); - } - - const applyPlacement = (clientX, clientY) => { - const rect = previewCanvasRef.current?.getBoundingClientRect(); - if (!rect) { - return; - } - const x = Math.max(10, Math.min(90, ((clientX - rect.left) / rect.width) * 100)); - const y = Math.max(10, Math.min(90, ((clientY - rect.top) / rect.height) * 100)); - setCaptionPlacement({ x, y }); - setCaptionPosition("custom"); - }; - - applyPlacement(event.clientX, event.clientY); - - const handlePointerMove = (moveEvent) => { - applyPlacement(moveEvent.clientX, moveEvent.clientY); - }; - const handlePointerUp = () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - notify(disabledSmartAvoidance ? "字幕位置已手动调整,智能避让已关闭" : "字幕位置已调整"); - }; - - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", handlePointerUp, { once: true }); - } - - function commitCaptionSegments(nextSegments, message, nextSelectedIndex = 0) { - if (trackLocks.caption) { - notify("字幕轨已锁定,无法修改片段"); - return; - } - - const normalized = nextSegments; - const nextScript = getCaptionScript(normalized); - pushScriptHistory(script); - setRedoStack([]); - setCaptionSegments(normalized); - setScript(nextScript); - setSelectedTrack("caption"); - setSelectedSegmentId( - normalized.length - ? normalized[Math.min(nextSelectedIndex, normalized.length - 1)]?.id ?? "" - : "", - ); - notify(message); - } - - function deleteCaptionSegment(segmentId = selectedSegmentId) { - if (trackLocks.caption) { - notify("字幕轨已锁定,无法删除"); - return; - } - - if (!captionSegments.length) { - notify("当前没有字幕片段可删除"); - return; - } - - const fallbackIndex = focusedSegmentIndex >= 0 ? focusedSegmentIndex : 0; - const segmentIndex = captionSegments.findIndex((segment) => segment.id === segmentId); - const index = segmentIndex >= 0 ? segmentIndex : fallbackIndex; - const nextSegments = captionSegments.filter((_, currentIndex) => currentIndex !== index); - commitCaptionSegments(nextSegments, "已删除当前字幕片段", Math.max(0, index - 1)); - } - - function getTimelineReorderIndex(track, clientX, clientY) { - const trackElement = document.querySelector(`[data-timeline-reorder-track="${track}"]`); - if (!trackElement) { - return timelineClipDragRef.current?.overIndex ?? 0; - } - - const trackRect = trackElement.getBoundingClientRect(); - if (clientY < trackRect.top - 28 || clientY > trackRect.bottom + 28) { - return timelineClipDragRef.current?.overIndex ?? 0; - } - - const segmentElements = Array.from( - trackElement.querySelectorAll(`[data-timeline-segment-track="${track}"]`), - ); - if (!segmentElements.length) { - return 0; - } - - for (let index = 0; index < segmentElements.length; index += 1) { - const rect = segmentElements[index].getBoundingClientRect(); - if (clientX < rect.left + rect.width / 2) { - return index; - } - } - - return segmentElements.length - 1; - } - - function commitTimelineClipReorder(track, fromIndex, toIndex) { - if (fromIndex === toIndex) { - return; - } - - if (track === "image") { - const sourceSegments = visualSegments.length - ? visualSegments - : renderedVisualSegments; - if (sourceSegments.length < 2) { - return; - } - - const nextSegments = reorderTimelineItems(sourceSegments, fromIndex, toIndex); - commitVisualSegments(nextSegments, "已调整视觉片段顺序", toIndex); - seekTo(getVisualSegmentTimeline(nextSegments)[toIndex]?.start ?? 0); - return; - } - - if (track === "caption") { - if (captionSegments.length < 2) { - return; - } - - const nextSegments = reorderTimelineItems(captionSegments, fromIndex, toIndex); - commitCaptionSegments(nextSegments, "已调整字幕片段顺序", toIndex); - seekTo(getSegmentStartTime(nextSegments, toIndex, captionTargetDuration)); - } - } - - function startTimelineClipDrag(event, track, segmentId, index) { - if ( - event.button !== 0 || - event.target.closest(".image-resize-handle") - ) { - return; - } - - if (trackLocks[track]) { - notify(track === "image" ? "图片轨已锁定,无法拖动片段" : "字幕轨已锁定,无法拖动片段"); - return; - } - - if (track === "image") { - setSelectedTrack("image"); - setSelectedVisualSegmentId(segmentId); - } else { - setSelectedTrack("caption"); - setSelectedSegmentId(segmentId); - } - - const segmentCount = track === "image" ? renderedVisualSegments.length : captionSegments.length; - if (segmentCount < 2) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const dragState = { - track, - segmentId, - fromIndex: index, - overIndex: index, - startX: event.clientX, - startY: event.clientY, - x: event.clientX, - y: event.clientY, - dragging: false, - }; - timelineClipDragRef.current = dragState; - setTimelineClipDrag(dragState); - - const handlePointerMove = (moveEvent) => { - const state = timelineClipDragRef.current; - if (!state || state.segmentId !== segmentId) { - return; - } - - const distance = Math.hypot(moveEvent.clientX - state.startX, moveEvent.clientY - state.startY); - if (!state.dragging && distance < 6) { - return; - } - - const overIndex = Math.max( - 0, - Math.min(segmentCount - 1, getTimelineReorderIndex(track, moveEvent.clientX, moveEvent.clientY)), - ); - const nextState = { - ...state, - overIndex, - x: moveEvent.clientX, - y: moveEvent.clientY, - dragging: true, - }; - timelineClipDragRef.current = nextState; - setTimelineClipDrag(nextState); - }; - - const handlePointerUp = () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - - const state = timelineClipDragRef.current; - timelineClipDragRef.current = null; - setTimelineClipDrag(null); - if (!state?.dragging) { - return; - } - - suppressTimelineClipClickRef.current = segmentId; - window.setTimeout(() => { - if (suppressTimelineClipClickRef.current === segmentId) { - suppressTimelineClipClickRef.current = ""; - } - }, 120); - - commitTimelineClipReorder(track, state.fromIndex, state.overIndex); - }; - - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", handlePointerUp, { once: true }); - } - - function undo() { - setUndoStack((stack) => { - const previous = stack.at(-1); - if (!previous) { - notify("没有可撤销的文本操作"); - return stack; - } - setRedoStack((redo) => [...redo, script]); - setScript(previous); - const previousSegments = createCaptionSegments(previous); - setCaptionSegments(previousSegments); - setSelectedSegmentId(previousSegments[0]?.id ?? ""); - notify("已撤销文本修改"); - return stack.slice(0, -1); - }); - } - - function redo() { - setRedoStack((stack) => { - const next = stack.at(-1); - if (!next) { - notify("没有可重做的文本操作"); - return stack; - } - setUndoStack((undoHistory) => [...undoHistory, script]); - setScript(next); - const nextSegments = createCaptionSegments(next); - setCaptionSegments(nextSegments); - setSelectedSegmentId(nextSegments[0]?.id ?? ""); - notify("已重做文本修改"); - return stack.slice(0, -1); - }); - } - - function replaceAudio(blob, duration, nextPeaks, nextStatusText) { - const nextUrl = URL.createObjectURL(blob); - const nextDuration = duration || estimateDuration(script); - const start = Math.max(0, currentTimeRef.current || 0); - const id = crypto.randomUUID(); - const segment = { - id, - blob, - url: nextUrl, - start, - duration: nextDuration, - peaks: nextPeaks, - volume: 1, - fadeIn: 0, - fadeOut: 0, - reversed: false, - name: selectedVoice?.name || t("voiceTrack"), - }; - setAudioSegments((segments) => [...segments, segment]); - setSelectedAudioSegmentId(id); - setSelectedTrack("audio"); - setTimelineHorizon((value) => Math.max(value, Math.ceil((start + nextDuration + 5) / 10) * 10)); - setCurrentTime(start); - setStatus("done"); - setStatusText(nextStatusText); - setProgress(100); - return segment; - } - - function clearAudioTrack(message = "配音音频已从时间线移除") { - audioSegmentRefs.current.forEach((audio) => audio.pause()); - audioSegments.forEach((segment) => URL.revokeObjectURL(segment.url)); - setAudioSegments([]); - setSelectedAudioSegmentId(""); - setCurrentTime(0); - setIsPlaying(false); - setStatus("ready"); - setStatusText("音频轨已清空"); - notify(message); - } - - function replaceMusic(blob, duration, nextPeaks, nextName, message = "背景音乐已添加到时间线") { - if (musicUrlRef.current) { - URL.revokeObjectURL(musicUrlRef.current); - } - const nextUrl = URL.createObjectURL(blob); - musicUrlRef.current = nextUrl; - setMusicBlob(blob); - setMusicUrl(nextUrl); - setMusicName(nextName); - setMusicDuration(duration || 0); - setMusicPeaks(nextPeaks); - setSelectedTrack("music"); - setActiveTool("audio"); - notify(message); - } - - function replaceSourceAudio( - blob, - duration, - nextPeaks, - nextName, - message = "视频原声已分离到时间线", - timelineStart = 0, - ) { - if (sourceAudioUrlRef.current) { - URL.revokeObjectURL(sourceAudioUrlRef.current); - } - const nextUrl = URL.createObjectURL(blob); - sourceAudioUrlRef.current = nextUrl; - const nextStart = Math.max(0, Math.min(MAX_TIMELINE_DURATION_SECONDS, timelineStart || 0)); - setSourceAudioBlob(blob); - setSourceAudioUrl(nextUrl); - setSourceAudioName(nextName); - setSourceAudioDuration(duration || 0); - setSourceAudioPeaks(nextPeaks); - setSourceAudioVolume(1); - setSourceAudioStart(nextStart); - setSelectedTrack("source"); - setActiveTool("audio"); - setStatus("done"); - setStatusText("视频原声已分离"); - setProgress(100); - notify(message); - } - - function clearSourceAudioTrack(message = "视频原声已从时间线移除") { - if (sourceAudioRef.current) { - sourceAudioRef.current.pause(); - } - if (sourceAudioUrlRef.current) { - URL.revokeObjectURL(sourceAudioUrlRef.current); - sourceAudioUrlRef.current = ""; - } - setSourceAudioBlob(null); - setSourceAudioUrl(""); - setSourceAudioName(""); - setSourceAudioDuration(0); - setSourceAudioPeaks([]); - setSourceAudioStart(0); - setCurrentTime((time) => - Math.min( - time, - Math.max( - audioBlob ? audioDuration : 0, - captionDuration, - musicBlob ? musicDuration : 0, - imageSrc ? imageDuration : 0, - estimateDuration(script), - ), - ), - ); - setIsPlaying(false); - setSelectedTrack("source"); - if (message) { - notify(message); - } - } - - function clearMusicTrack(message = "背景音乐已从时间线移除") { - if (musicRef.current) { - musicRef.current.pause(); - } - if (musicUrlRef.current) { - URL.revokeObjectURL(musicUrlRef.current); - musicUrlRef.current = ""; - } - setMusicBlob(null); - setMusicUrl(""); - setMusicName(""); - setMusicDuration(0); - setMusicPeaks([]); - setCurrentTime((time) => - Math.min( - time, - Math.max( - audioBlob ? audioDuration : 0, - captionDuration, - sourceAudioBlob ? sourceAudioStart + sourceAudioDuration : 0, - imageSrc ? imageDuration : 0, - estimateDuration(script), - ), - ), - ); - setIsPlaying(false); - setSelectedTrack("music"); - notify(message); - } - - function getVisualDurationForAsset(asset, fallbackDuration = 4) { - if (asset?.type === "video" && asset.duration) { - return Math.min(MAX_TIMELINE_DURATION_SECONDS, Math.max(MIN_VISUAL_SEGMENT_SECONDS, asset.duration)); - } - - return Math.min( - MAX_TIMELINE_DURATION_SECONDS, - Math.max(MIN_VISUAL_SEGMENT_SECONDS, asset?.duration || fallbackDuration), - ); - } - - function getCurrentVisualAssetSnapshot() { - return { - id: previewVisualSegment?.assetId || "", - assetId: previewVisualSegment?.assetId || "", - type: previewVisualSegment?.type || visualType, - src: previewVisualSegment?.src || imageSrc, - name: previewVisualSegment?.name || imageName, - meta: previewVisualSegment?.meta || imageMeta, - blob: previewVisualSegment?.blob || null, - width: previewVisualSegment?.width || 0, - height: previewVisualSegment?.height || 0, - sourceStart: Math.max(0, Number(previewVisualSegment?.sourceStart) || 0), - trackFrames: previewVisualSegment?.trackFrames || [], - }; - } - - function setCurrentVisualAsset(asset) { - setImageSrc(asset?.src ?? ""); - setImageName(asset?.name ?? ""); - setImageMeta(asset?.meta ?? ""); - setVisualType(asset?.type ?? "image"); - } - - function commitVisualSegments(nextSegments, message, selectedIndex = 0) { - const normalizedSegments = nextSegments - .filter((segment) => segment.duration > 0.05) - .map((segment) => ({ - ...segment, - duration: Math.max( - MIN_VISUAL_SEGMENT_SECONDS, - Math.min(MAX_TIMELINE_DURATION_SECONDS, segment.duration), - ), - })); - const nextDuration = Math.min( - MAX_TIMELINE_DURATION_SECONDS, - getVisualSegmentsTotal(normalizedSegments), - ); - - setVisualSegments(normalizedSegments); - setImageDuration(nextDuration); - setImageClipCount(getImageThumbnailCount(nextDuration)); - setSelectedTrack("image"); - const selectedSegment = normalizedSegments.length - ? normalizedSegments[Math.min(Math.max(0, selectedIndex), normalizedSegments.length - 1)] - : null; - setSelectedVisualSegmentId(selectedSegment?.id ?? ""); - if (selectedSegment?.src) { - setCurrentVisualAsset(selectedSegment); - } - setCurrentTime((time) => Math.min(time, Math.max(nextDuration, captionDuration, estimateDuration(script)))); - notify(message); - } - - function replaceVisualTimeline(asset, duration = getVisualDurationForAsset(asset)) { - const segment = createVisualSegment(duration, asset); - setFitMode("contain"); - setCurrentVisualAsset(asset); - setVisualSegments([segment]); - setSelectedVisualSegmentId(segment.id); - setImageDuration(segment.duration); - setImageClipCount(getImageThumbnailCount(segment.duration)); - } - - function appendVisualAssetToTimeline(asset) { - if (trackLocks.image) { - notify("图片轨已锁定,无法添加素材"); - return; - } - - const sourceSegments = visualSegments.length - ? visualSegments - : imageSrc - ? [createVisualSegment(imageDuration || 4, getCurrentVisualAssetSnapshot())] - : []; - const totalDuration = getVisualSegmentsTotal(sourceSegments); - const availableDuration = MAX_TIMELINE_DURATION_SECONDS - totalDuration; - if (availableDuration < MIN_VISUAL_SEGMENT_SECONDS) { - notify("视觉轨道已经达到 30 分钟上限"); - return; - } - - const segmentDuration = Math.min(getVisualDurationForAsset(asset), availableDuration); - const nextSegment = createVisualSegment(segmentDuration, asset); - setFitMode("contain"); - setCurrentVisualAsset(asset); - commitVisualSegments( - [...sourceSegments, nextSegment], - `${asset.type === "video" ? "视频" : "图片"}素材已追加到图片轨`, - sourceSegments.length, - ); - seekTo(totalDuration); - if (asset.type === "video") { - extractVideoSourceAudio(asset, totalDuration); - } - } - - function getTimelineTimeFromDropPercent(percent = 0) { - const safePercent = Math.max(0, Math.min(100, Number.isFinite(percent) ? percent : 0)); - const duration = timelineDurationRef.current || Math.max(estimatedDuration, 10); - return Math.max(0, Math.min(MAX_TIMELINE_DURATION_SECONDS, (safePercent / 100) * duration)); - } - - function commitStickerSegments(nextSegments, message, selectedId = "") { - const normalizedSegments = nextSegments - .filter((segment) => segment?.src && segment.duration > 0.05) - .map((segment) => { - const duration = Math.max( - MIN_VISUAL_SEGMENT_SECONDS, - Math.min(MAX_TIMELINE_DURATION_SECONDS, segment.duration), - ); - return { - ...segment, - duration, - start: Math.max(0, Math.min(MAX_TIMELINE_DURATION_SECONDS - duration, segment.start || 0)), - }; - }); - - setStickerSegments(normalizedSegments); - setSelectedTrack("sticker"); - setActiveTool("stickers"); - const selectedSegment = - normalizedSegments.find((segment) => segment.id === selectedId) ?? - normalizedSegments[Math.max(0, normalizedSegments.length - 1)] ?? - null; - setSelectedStickerSegmentId(selectedSegment?.id ?? ""); - if (selectedSegment?.stickerId) { - setSelectedStickerId(selectedSegment.stickerId); - } - notify(message); - } - - function addStickerAssetToTimeline(asset, options = {}) { - if (trackLocks.sticker) { - notify("贴纸轨已锁定,无法添加贴纸"); - return; - } - - if (!asset?.src) { - notify("当前贴纸素材不可用"); - return; - } - - const startTime = Math.min( - MAX_TIMELINE_DURATION_SECONDS - DEFAULT_STICKER_SEGMENT_SECONDS, - getTimelineTimeFromDropPercent(options.percent ?? 0), - ); - const nextSegment = createStickerSegment(asset, startTime, DEFAULT_STICKER_SEGMENT_SECONDS); - commitStickerSegments([...stickerSegments, nextSegment], "贴纸已添加到贴纸轨", nextSegment.id); - seekTo(startTime); - } - - function updateVisualAssetInTimeline(assetId, updates) { - if (!assetId) { - return; - } - - setVisualSegments((segments) => { - const nextSegments = segments.map((segment) => - segment.assetId === assetId - ? { - ...segment, - ...updates, - duration: updates.duration - ? Math.max(MIN_VISUAL_SEGMENT_SECONDS, Math.min(MAX_TIMELINE_DURATION_SECONDS, updates.duration)) - : segment.duration, - } - : segment, - ); - const nextDuration = getVisualSegmentsTotal(nextSegments); - setImageDuration(nextDuration); - setImageClipCount(getImageThumbnailCount(nextDuration)); - return nextSegments; - }); - - if (previewVisualSegment?.assetId === assetId || previewVisualSegment?.src === updates.src) { - setImageMeta(updates.meta ?? imageMeta); - if (updates.type) { - setVisualType(updates.type); - } - } - } - - function clearImageTrack(message = "图片素材已从时间线移除") { - const remainingDuration = Math.max( - audioBlob ? audioDuration : 0, - captionDuration, - sourceAudioBlob ? sourceAudioStart + sourceAudioDuration : 0, - musicBlob ? musicDuration : 0, - estimateDuration(script), - ); - setImageSrc(""); - setImageName(""); - setImageMeta(""); - setVisualType("image"); - setImageClipCount(0); - setImageDuration(0); - setVisualSegments([]); - setSelectedVisualSegmentId(""); - setCurrentTime((time) => Math.min(time, remainingDuration)); - setSelectedTrack("image"); - notify(message); - } - - async function commitAudio(blob, nextStatusText) { - const decoded = await decodeWaveform(blob); - const audioSegment = replaceAudio(blob, decoded.duration, decoded.peaks, nextStatusText); - const generatedCaptions = createCaptionSegments(script); - const generatedTimeline = getCaptionTimeline(generatedCaptions, audioSegment.duration); - const boundCaptions = generatedCaptions.map((segment, index) => ({ - ...segment, - audioSegmentId: audioSegment.id, - start: audioSegment.start + generatedTimeline[index].start, - end: audioSegment.start + generatedTimeline[index].end, - })); - setCaptionSegments((segments) => [ - ...segments.filter((segment) => segment.audioSegmentId), - ...boundCaptions, - ].sort((a, b) => (a.start || 0) - (b.start || 0))); - setSelectedSegmentId(boundCaptions[0]?.id ?? ""); - setHistoryItems((items) => [ - { - id: crypto.randomUUID(), - blob, - voiceId: selectedVoiceId, - voiceName: selectedVoice.name, - script, - duration: decoded.duration || estimateDuration(script), - peaks: decoded.peaks, - createdAt: formatSavedTime(), - }, - ...items.slice(0, 8), - ]); - } - - function updateAudioSegment(segmentId, patch) { - setAudioSegments((segments) => segments.map((segment) => { - if (segment.id !== segmentId) return segment; - const next = { ...segment, ...patch }; - if (Number.isFinite(patch.start) && patch.start !== segment.start) { - const delta = patch.start - segment.start; - setCaptionSegments((captions) => captions.map((caption) => - caption.audioSegmentId === segmentId - ? { ...caption, start: caption.start + delta, end: caption.end + delta } - : caption, - )); - } - setTimelineHorizon((value) => Math.max(value, Math.ceil((next.start + next.duration + 5) / 10) * 10)); - return next; - })); - } - - async function toggleAudioSegmentReverse(segmentId) { - const segment = audioSegments.find((item) => item.id === segmentId); - if (!segment || segment.reversing) return; - const audio = audioSegmentRefs.current.get(segmentId); - audio?.pause(); - setAudioSegments((segments) => segments.map((item) => item.id === segmentId ? { ...item, reversing: true } : item)); - try { - const originalBlob = segment.originalBlob || segment.blob; - const originalPeaks = segment.originalPeaks || segment.peaks; - const nextBlob = segment.reversed ? originalBlob : await reverseAudioBlob(originalBlob); - const nextPeaks = segment.reversed ? originalPeaks : [...originalPeaks].reverse(); - const nextUrl = URL.createObjectURL(nextBlob); - URL.revokeObjectURL(segment.url); - setAudioSegments((segments) => segments.map((item) => item.id === segmentId ? { - ...item, - blob: nextBlob, - url: nextUrl, - peaks: nextPeaks, - reversed: !segment.reversed, - reversing: false, - originalBlob, - originalPeaks, - } : item)); - notify(segment.reversed ? t("audioReverseRestored") : t("audioReversed")); - } catch (error) { - setAudioSegments((segments) => segments.map((item) => item.id === segmentId ? { ...item, reversing: false } : item)); - notify(`${t("audioReverseFailed")}:${error instanceof Error ? error.message : String(error)}`); - } - } - - function deleteAudioSegment(segmentId) { - const segment = audioSegments.find((item) => item.id === segmentId); - if (segment) URL.revokeObjectURL(segment.url); - setAudioSegments((segments) => segments.filter((item) => item.id !== segmentId)); - setCaptionSegments((segments) => segments.filter((item) => item.audioSegmentId !== segmentId)); - setSelectedAudioSegmentId((current) => current === segmentId ? "" : current); - notify(t("audioClipDeleted")); - } - - async function commitRecordedVoice(blob, extension = "webm") { - setRecordingState("processing"); - setStatus("generating"); - setStatusText(t("recording")); - setProgress(72); - - try { - const decoded = await decodeWaveform(blob); - const createdAt = formatSavedTime(); - const recording = { - id: crypto.randomUUID(), - blob, - name: `${t("recordVoice")} ${createdAt}`, - duration: decoded.duration, - peaks: decoded.peaks, - createdAt, - extension, - }; - - replaceAudio(blob, decoded.duration, decoded.peaks, t("recordingReady")); - setRecordedVoices((items) => [recording, ...items.slice(0, 8)]); - setSelectedTrack("audio"); - setActiveTool("audio"); - setVoiceTab("mine"); - notify(t("recordingReady")); - } catch (error) { - console.error(error); - setStatus("error"); - setStatusText(error instanceof Error ? error.message : t("recordingPermissionDenied")); - notify(t("recordingPermissionDenied")); - } finally { - setRecordingState("idle"); - setProgress(0); - } - } - - async function startVoiceRecording() { - if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") { - notify(t("recordingUnsupported")); - return; - } - - if (recordingState === "recording") { - return; - } - - try { - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - }, - }); - const format = getAudioRecordingFormat(); - const recorder = format?.mimeType - ? new MediaRecorder(stream, { mimeType: format.mimeType }) - : new MediaRecorder(stream); - - voiceRecorderStreamRef.current = stream; - voiceRecorderChunksRef.current = []; - voiceRecorderRef.current = recorder; - voiceRecorderStartedAtRef.current = performance.now(); - setRecordingElapsed(0); - setRecordingState("recording"); - setStatus("generating"); - setStatusText(t("recording")); - setProgress(0); - setSelectedTrack("audio"); - setActiveTool("audio"); - setVoiceTab("mine"); - - recorder.ondataavailable = (event) => { - if (event.data?.size) { - voiceRecorderChunksRef.current.push(event.data); - } - }; - - recorder.onstop = () => { - const chunks = voiceRecorderChunksRef.current; - const blob = new Blob(chunks, { type: recorder.mimeType || format?.mimeType || "audio/webm" }); - stream.getTracks().forEach((track) => track.stop()); - voiceRecorderStreamRef.current = null; - voiceRecorderRef.current = null; - window.clearInterval(voiceRecorderTimerRef.current); - voiceRecorderTimerRef.current = 0; - setRecordingElapsed((performance.now() - voiceRecorderStartedAtRef.current) / 1000); - - if (blob.size > 0) { - commitRecordedVoice(blob, format?.extension ?? "webm"); - } else { - setRecordingState("idle"); - notify(t("recordingPermissionDenied")); - } - }; - - recorder.start(250); - voiceRecorderTimerRef.current = window.setInterval(() => { - setRecordingElapsed((performance.now() - voiceRecorderStartedAtRef.current) / 1000); - }, 250); - } catch (error) { - console.error(error); - setRecordingState("idle"); - setStatus("error"); - setStatusText(t("recordingPermissionDenied")); - notify(t("recordingPermissionDenied")); - voiceRecorderStreamRef.current?.getTracks().forEach((track) => track.stop()); - voiceRecorderStreamRef.current = null; - } - } - - function stopVoiceRecording() { - const recorder = voiceRecorderRef.current; - if (!recorder || recorder.state === "inactive") { - return; - } - - setRecordingState("processing"); - recorder.stop(); - } - - function useRecordedVoice(recording) { - replaceAudio(recording.blob, recording.duration, recording.peaks, recording.name); - setSelectedTrack("audio"); - setActiveTool("audio"); - setVoiceTab("mine"); - notify(t("recordingReady")); - } - - async function generateVoiceover() { - const rawText = script.trim(); - if (!rawText || status === "generating" || status === "captioning") { - return; - } - - let preparedText; - try { - preparedText = prepareTextForVoice(rawText, selectedVoice); - } catch (error) { - const message = error instanceof TtsInputError - ? t(error.code) - : error instanceof Error ? error.message : t("ttsErrorVoiceMismatch"); - setStatus("error"); - setStatusText(message); - setProgress(0); - notify(message); - return; - } - - setVoiceTab("synthesis"); - setStatus("generating"); - setStatusText(t("ttsStatusPreparingModel")); - setProgress(6); - if (preparedText.warningKey) { - notify(t(preparedText.warningKey)); - } - - try { - let blob; - - if (selectedVoice.engine === "piper") { - const tts = await import("@diffusionstudio/vits-web"); - const cacheWasCleared = await clearPiperCacheIfStorageTight(tts); - if (cacheWasCleared) { - notify(t("ttsNoticePiperCacheCleared")); - } - setStatusText(t("ttsStatusLoadingChineseModel")); - const progressCallback = (event) => { - if (event?.total) { - const nextProgress = Math.round((event.loaded / event.total) * 76); - setProgress((currentProgress) => Math.max(currentProgress, Math.min(88, Math.max(12, nextProgress)))); - } - }; - const piperInput = { - text: preparedText.text, - voiceId: selectedVoice.id, - }; - - try { - blob = await tts.predict(piperInput, progressCallback); - } catch (error) { - if (!isStorageQuotaError(error)) { - throw error; - } - - setStatusText(t("ttsStatusClearingCache")); - await tts.flush?.(); - blob = await tts.predict(piperInput, progressCallback); - } - } else { - const { KokoroTTS } = await import("kokoro-js"); - setStatusText(t("ttsStatusLoadingKokoro")); - const tts = await KokoroTTS.from_pretrained(MODEL_ID, { - dtype: "q8", - device: "wasm", - progress_callback: (event) => { - if (event?.progress) { - setProgress((currentProgress) => - Math.max(currentProgress, Math.min(86, Math.max(10, Math.round(event.progress)))), - ); - } - }, - }); - setStatusText(t("ttsStatusGeneratingEnglish")); - const audio = await tts.generate(preparedText.text, { - voice: selectedVoice.id, - speed, - }); - blob = audio.toBlob(); - } - - setStatusText(t("ttsStatusDecodingWaveform")); - await commitAudio(blob, `${selectedVoice.name} · ${t("ttsGenerated")}`); - notify(t("ttsNoticeGenerated")); - } catch (error) { - console.error(error); - const message = - error instanceof TtsInputError - ? t(error.code) - : selectedVoice.engine === "piper" && isPiperSymbolError(error) - ? t("ttsErrorUnsupportedPiperSymbols") - : isStorageQuotaError(error) - ? t("ttsErrorStorageQuota") - : error instanceof Error - ? error.message - : t("ttsErrorGenerationFailed"); - setStatus("error"); - setStatusText(message); - setProgress(0); - notify(message); - } - } - - async function generateCaptionsFromSourceAudio() { - if (status === "generating" || status === "captioning") { - return; - } - - if (trackLocks.caption) { - notify("字幕轨已锁定,无法生成自动字幕"); - return; - } - - if (!sourceAudioBlob) { - notify("请先上传视频或把音频拖到原声轨"); - return; - } - - setStatus("captioning"); - setStatusText("准备自动字幕模型"); - setProgress(4); - setActiveTool("audio"); - - try { - const result = await transcribeAudioToCaptionSegments(sourceAudioBlob, { - preferredLanguage: uiLanguage, - timelineOffset: sourceAudioStart, - onProgress: ({ progress: nextProgress, phase }) => { - setProgress((currentProgress) => Math.max(currentProgress, nextProgress)); - setStatusText(phase); - }, - }); - - pushScriptHistory(script); - setRedoStack([]); - setCaptionSegments(result.segments); - setScript(result.text); - setSelectedSegmentId(result.segments[0]?.id ?? ""); - setSelectedTrack("caption"); - setActiveTool("caption"); - setCaptionsEnabled(true); - setTrackVisibility((visibility) => ({ ...visibility, caption: true })); - setStatus("done"); - setStatusText(`已生成 ${result.segments.length} 条自动字幕`); - setProgress(100); - seekTo(result.segments[0]?.start ?? 0); - notify(`已生成 ${result.segments.length} 条自动字幕`); - } catch (error) { - console.error(error); - setStatus("error"); - setStatusText(error instanceof Error ? error.message : "自动字幕生成失败"); - setProgress(0); - notify("自动字幕生成失败,请换一段有清晰人声的音频试试"); - } - } - - async function extractVideoSourceAudio(asset, timelineStart = 0) { - if (!asset?.blob) { - clearSourceAudioTrack(null); - notify("当前视频素材缺少原文件,无法分离原声"); - return; - } - - setStatus("generating"); - setStatusText("加载 FFmpeg WASM 分离视频原声"); - setProgress(12); - - try { - const extractedBlob = await extractAudioFromVideo(asset.blob, asset.name); - setStatusText("解析视频原声波形"); - setProgress(78); - const decoded = await decodeWaveform(extractedBlob, 96); - - if (!decoded.duration) { - throw new Error("视频没有可识别的音频轨"); - } - - replaceSourceAudio( - extractedBlob, - decoded.duration, - decoded.peaks, - `${asset.name.replace(/\.[^.]+$/, "")} 原声.wav`, - "视频原声已分离到时间线", - timelineStart, - ); - } catch (error) { - console.warn(error); - clearSourceAudioTrack(null); - setStatus("ready"); - setStatusText("视频未检测到可分离原声"); - setProgress(0); - notify("视频画面已添加,但没有可分离的原声音轨"); - } - } - - function findAssetById(assetId) { - if (!assetId) { - return null; - } - - const mediaAsset = [...userAssets, ...builtInAssets].find((asset) => asset.id === assetId); - if (mediaAsset) { - return mediaAsset; - } - - return getStickerDragAsset(STICKERS.find((sticker) => sticker.id === assetId)); - } - - function getDraggedAsset(event) { - const assetId = - event.dataTransfer?.getData(ASSET_DRAG_MIME) || - event.dataTransfer?.getData("text/plain") || - draggedAssetIdRef.current || - draggedAssetId; - return findAssetById(assetId); - } - - function getActiveDraggedAsset() { - return findAssetById(draggedAssetIdRef.current || draggedAssetId); - } - - function getTimelineDropPercent(clientX, rect) { - return rect?.width - ? Math.max(8, Math.min(92, ((clientX - rect.left) / rect.width) * 100)) - : 50; - } - - function canDropAssetOnTrack(asset, track) { - if (!asset || trackLocks[track]) { - return false; - } - - if (track === "image") { - return asset.type === "image" || asset.type === "video"; - } - - if (track === "sticker") { - return asset.type === "sticker"; - } - - if (track === "audio" || track === "music") { - return asset.type === "audio"; - } - - if (track === "source") { - return asset.type === "video"; - } - - return false; - } - - function handleAssetDragStart(event, asset) { - draggedAssetIdRef.current = asset.id; - setDraggedAssetId(asset.id); - setAssetDropTargetTrack(""); - event.dataTransfer.effectAllowed = "copy"; - event.dataTransfer.setData(ASSET_DRAG_MIME, asset.id); - event.dataTransfer.setData("text/plain", asset.id); - } - - function handleAssetDragEnd() { - draggedAssetIdRef.current = ""; - setDraggedAssetId(""); - setAssetDropTargetTrack(""); - setAssetDropPosition({ track: "", percent: 50 }); - } - - function getDropTrackInfoFromPoint(clientX, clientY) { - const elementAtPoint = document.elementFromPoint(clientX, clientY); - if (!(elementAtPoint instanceof Element)) { - return { track: "", percent: 50 }; - } - - const activeDraggedAsset = getActiveDraggedAsset(); - const timelineElement = elementAtPoint.closest(".track-scroll, .tracks"); - if (activeDraggedAsset?.type === "sticker" && timelineElement instanceof HTMLElement) { - const rect = trackScrollRef.current?.getBoundingClientRect() ?? timelineElement.getBoundingClientRect(); - return { track: "sticker", percent: getTimelineDropPercent(clientX, rect) }; - } - - const trackElement = elementAtPoint.closest("[data-asset-drop-track]"); - const track = trackElement?.dataset.assetDropTrack ?? ""; - if (!track || !(trackElement instanceof HTMLElement)) { - return { track, percent: 50 }; - } - - const percent = getTimelineDropPercent(clientX, trackElement.getBoundingClientRect()); - return { track, percent }; - } - - function getDropTrackFromPoint(clientX, clientY) { - return getDropTrackInfoFromPoint(clientX, clientY).track; - } - - function triggerAssetDropPulse(track) { - if (!track) { - return; - } - - window.clearTimeout(assetDropPulseTimerRef.current); - setAssetDropPulseTrack(""); - window.requestAnimationFrame(() => { - setAssetDropPulseTrack(track); - assetDropPulseTimerRef.current = window.setTimeout(() => { - setAssetDropPulseTrack(""); - }, 620); - }); - } - - function handleAssetPointerDown(event, asset) { - if (event.button !== 0) { - return; - } - - const target = event.target; - if (target instanceof Element && target.closest(".asset-delete")) { - return; - } - - setSelectedLibraryAssetId(asset.id); - pointerAssetDragRef.current = { - assetId: asset.id, - startX: event.clientX, - startY: event.clientY, - dragging: false, - }; - - const handlePointerMove = (moveEvent) => { - const dragState = pointerAssetDragRef.current; - if (!dragState || dragState.assetId !== asset.id) { - return; - } - - const distance = Math.hypot( - moveEvent.clientX - dragState.startX, - moveEvent.clientY - dragState.startY, - ); - if (!dragState.dragging && distance < 7) { - return; - } - - moveEvent.preventDefault(); - - if (!dragState.dragging) { - dragState.dragging = true; - draggedAssetIdRef.current = asset.id; - setDraggedAssetId(asset.id); - } - - const dropInfo = getDropTrackInfoFromPoint(moveEvent.clientX, moveEvent.clientY); - const draggedAsset = findAssetById(dragState.assetId); - const nextTargetTrack = - draggedAsset?.type === "sticker" && dropInfo.track ? "sticker" : dropInfo.track; - const acceptedTargetTrack = canDropAssetOnTrack(draggedAsset, nextTargetTrack) ? nextTargetTrack : ""; - setAssetDropTargetTrack(acceptedTargetTrack); - setAssetDropPosition( - acceptedTargetTrack - ? { track: acceptedTargetTrack, percent: dropInfo.percent } - : { track: "", percent: 50 }, - ); - setAssetDragPreview({ - id: asset.id, - name: asset.name, - type: asset.type, - src: asset.src, - x: moveEvent.clientX, - y: moveEvent.clientY, - }); - }; - - const cleanupPointerDrag = () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - window.removeEventListener("pointercancel", handlePointerCancel); - pointerAssetDragRef.current = null; - setAssetDragPreview(null); - setAssetDropTargetTrack(""); - setAssetDropPosition({ track: "", percent: 50 }); - draggedAssetIdRef.current = ""; - setDraggedAssetId(""); - }; - - const handlePointerCancel = () => { - cleanupPointerDrag(); - }; - - const handlePointerUp = (upEvent) => { - const dragState = pointerAssetDragRef.current; - const dropInfo = getDropTrackInfoFromPoint(upEvent.clientX, upEvent.clientY); - cleanupPointerDrag(); - - if (!dragState?.dragging) { - return; - } - - suppressAssetClickRef.current = dragState.assetId; - window.setTimeout(() => { - if (suppressAssetClickRef.current === dragState.assetId) { - suppressAssetClickRef.current = ""; - } - }, 300); - - const draggedAsset = findAssetById(dragState.assetId); - const dropTrack = draggedAsset?.type === "sticker" && dropInfo.track ? "sticker" : dropInfo.track; - if (canDropAssetOnTrack(draggedAsset, dropTrack)) { - triggerAssetDropPulse(dropTrack); - void applyAssetToTrack(draggedAsset, dropTrack, { percent: dropInfo.percent }); - } - }; - - window.addEventListener("pointermove", handlePointerMove, { passive: false }); - window.addEventListener("pointerup", handlePointerUp); - window.addEventListener("pointercancel", handlePointerCancel); - } - - function handleAssetClick(event, asset) { - if (suppressAssetClickRef.current === asset.id) { - suppressAssetClickRef.current = ""; - event.preventDefault(); - event.stopPropagation(); - return; - } - - setSelectedLibraryAssetId(asset.id); - if (event.detail >= 2) { - const targetTrack = asset.type === "audio" ? "music" : "image"; - void applyAssetToTrack(asset, targetTrack); - return; - } - notify("素材已选中,请拖到对应轨道使用"); - } - - function handleStickerClick(event, sticker) { - if (suppressAssetClickRef.current === sticker.id) { - suppressAssetClickRef.current = ""; - event.preventDefault(); - event.stopPropagation(); - return; - } - - setSelectedStickerId(sticker.id); - setSelectedStickerSegmentId(""); - notify(t("stickerApplied")); - } - - function handleTrackAssetDragOver(event, track) { - const asset = getDraggedAsset(event); - const targetTrack = asset?.type === "sticker" ? "sticker" : track; - if (!canDropAssetOnTrack(asset, targetTrack)) { - if (assetDropTargetTrack === targetTrack) { - setAssetDropTargetTrack(""); - setAssetDropPosition({ track: "", percent: 50 }); - } - return; - } - - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - const rect = - targetTrack === "sticker" - ? trackScrollRef.current?.getBoundingClientRect() ?? event.currentTarget.getBoundingClientRect() - : event.currentTarget.getBoundingClientRect(); - const percent = getTimelineDropPercent(event.clientX, rect); - if (assetDropTargetTrack !== targetTrack) { - setAssetDropTargetTrack(targetTrack); - } - setAssetDropPosition({ track: targetTrack, percent }); - } - - function handleTrackAssetDragLeave(event, track) { - const relatedTarget = event.relatedTarget; - if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { - return; - } - - const targetTrack = getActiveDraggedAsset()?.type === "sticker" ? "sticker" : track; - setAssetDropTargetTrack((currentTrack) => (currentTrack === targetTrack ? "" : currentTrack)); - setAssetDropPosition((currentPosition) => - currentPosition.track === targetTrack ? { track: "", percent: 50 } : currentPosition, - ); - } - - async function applyAssetToTrack(asset, track, options = {}) { - if (!canDropAssetOnTrack(asset, track)) { - notify("请把素材拖到匹配的轨道"); - return; - } - - setSelectedLibraryAssetId(asset.id); - - if (track === "sticker") { - addStickerAssetToTimeline(asset, options); - return; - } - - if (track === "image") { - appendVisualAssetToTimeline(asset); - return; - } - - if (track === "music") { - await selectAsset(asset); - return; - } - - if (track === "audio") { - if (!asset.blob) { - notify("当前音频素材不可用,请重新上传"); - return; - } - - const decoded = asset.peaks?.length - ? { duration: asset.duration, peaks: asset.peaks } - : await decodeWaveform(asset.blob, 96); - replaceAudio(asset.blob, decoded.duration, decoded.peaks, "音频已写入配音轨"); - setSelectedTrack("audio"); - setActiveTool("audio"); - notify("音频已拖入配音音频轨"); - return; - } - - if (track === "source") { - setSelectedTrack("source"); - setActiveTool("audio"); - await extractVideoSourceAudio(asset); - } - } - - function handleTrackAssetDrop(event, track) { - const asset = getDraggedAsset(event); - const targetTrack = asset?.type === "sticker" ? "sticker" : track; - if (!canDropAssetOnTrack(asset, targetTrack)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - const rect = - targetTrack === "sticker" - ? trackScrollRef.current?.getBoundingClientRect() ?? event.currentTarget.getBoundingClientRect() - : event.currentTarget.getBoundingClientRect(); - const percent = getTimelineDropPercent(event.clientX, rect); - draggedAssetIdRef.current = ""; - setDraggedAssetId(""); - setAssetDropTargetTrack(""); - setAssetDropPosition({ track: "", percent: 50 }); - triggerAssetDropPulse(targetTrack); - void applyAssetToTrack(asset, targetTrack, { percent }); - } - - function handleVisualStyleDrop(event) { - const payload = event.dataTransfer?.getData("application/x-timeline-visual-style") || ""; - const [kind, styleId] = payload.split(":"); - if (!styleId || (kind !== "effect" && kind !== "transition")) { - handleTrackAssetDrop(event, "image"); - return; - } - const clip = event.target.closest?.("[data-timeline-segment-id]"); - const segmentId = clip?.dataset.timelineSegmentId; - if (!segmentId) { - notify("请将效果或转场拖到具体的画面片段上"); - return; - } - event.preventDefault(); - event.stopPropagation(); - setVisualSegments((segments) => segments.map((segment) => segment.id === segmentId ? { ...segment, [kind === "effect" ? "filterId" : "transitionId"]: styleId } : segment)); - setSelectedVisualSegmentId(segmentId); - setSelectedTrack("image"); - if (kind === "effect") setSelectedFilterId(styleId); - else setSelectedTransitionId(styleId); - notify(kind === "effect" ? "效果已应用到该画面片段" : "转场已绑定到该片段的结尾"); - } - - async function selectAsset(asset) { - if (asset.type === "audio") { - if (!asset.blob) { - notify("当前音频素材不可用,请重新上传"); - return; - } - const decoded = asset.peaks?.length - ? { duration: asset.duration, peaks: asset.peaks } - : await decodeWaveform(asset.blob, 96); - replaceMusic(asset.blob, decoded.duration, decoded.peaks, asset.name); - return; - } - - const nextDuration = getVisualDurationForAsset(asset); - replaceVisualTimeline(asset, nextDuration); - notify(`${asset.type === "video" ? "视频" : "图片"}素材已应用到预览和时间线`); - if (asset.type === "video") { - extractVideoSourceAudio(asset); - } - } - - function deleteUserAsset(asset) { - removeVisionRecordsForAsset(asset); - const hasOtherAssetUsingUrl = userAssets.some( - (item) => item.id !== asset.id && item.src === asset.src, - ); - if (selectedLibraryAssetId === asset.id) { - setSelectedLibraryAssetId(""); - } - setUserAssets((items) => items.filter((item) => item.id !== asset.id)); - if (!hasOtherAssetUsingUrl && imageUrlRefs.current.has(asset.src)) { - URL.revokeObjectURL(asset.src); - imageUrlRefs.current.delete(asset.src); - } - if (asset.type === "audio" && asset.blob === musicBlob) { - clearMusicTrack("背景音乐素材已删除,时间线已同步清空"); - } else if ( - asset.type !== "audio" && - (asset.src === imageSrc || visualSegments.some((segment) => segment.assetId === asset.id || segment.src === asset.src)) - ) { - const nextSegments = visualSegments.filter( - (segment) => segment.assetId !== asset.id && segment.src !== asset.src, - ); - if (asset.type === "video" && sourceAudioBlob && asset.src === imageSrc) { - clearSourceAudioTrack(null); - } - if (nextSegments.length) { - commitVisualSegments(nextSegments, "素材已删除,对应视觉片段已移除"); - } else { - clearImageTrack("视觉素材已删除,时间线已同步清空"); - } - } else { - notify("素材已删除"); - } - } - - function handleFiles(files) { - const mediaFiles = Array.from(files ?? []).filter((item) => - SUPPORTED_MEDIA_TYPES.some((typePrefix) => item.type.startsWith(typePrefix)), - ); - if (!mediaFiles.length) { - notify("请选择图片、视频或音频素材"); - return; - } - - const uploadedAssets = mediaFiles.map((file) => { - const url = URL.createObjectURL(file); - imageUrlRefs.current.add(url); - const type = file.type.startsWith("video/") - ? "video" - : file.type.startsWith("audio/") - ? "audio" - : "image"; - return { - id: crypto.randomUUID(), - type, - src: url, - name: file.name, - meta: "读取中", - blob: file, - duration: type === "video" ? 0 : 4, - width: 0, - height: 0, - trackFrames: [], - }; - }); - - const primaryAsset = uploadedAssets[0]; - setSelectedLibraryAssetId(primaryAsset.id); - setUserAssets((assets) => [...uploadedAssets, ...assets]); - - uploadedAssets.forEach((asset) => { - if (asset.type === "audio") { - decodeWaveform(asset.blob, 96) - .then((decoded) => { - const meta = `音频 · ${formatTime(decoded.duration)}`; - setUserAssets((assets) => - assets.map((item) => - item.id === asset.id - ? { ...item, meta, duration: decoded.duration, peaks: decoded.peaks } - : item, - ), - ); - }) - .catch(() => { - setUserAssets((assets) => - assets.map((item) => (item.id === asset.id ? { ...item, meta: "音频读取失败" } : item)), - ); - }); - return; - } - - if (asset.type === "video") { - const video = document.createElement("video"); - video.preload = "metadata"; - video.onloadedmetadata = () => { - const duration = Math.min( - MAX_TIMELINE_DURATION_SECONDS, - Math.max(0.5, video.duration || 1), - ); - const width = video.videoWidth || 0; - const height = video.videoHeight || 0; - const meta = `${width || "?"} x ${height || "?"} · ${formatClock(duration)}`; - const updates = { meta, duration, width, height, type: "video" }; - setUserAssets((assets) => - assets.map((item) => - item.id === asset.id ? { ...item, ...updates } : item, - ), - ); - updateVisualAssetInTimeline(asset.id, updates); - extractVideoTrackFrames(asset.src, { duration, width, height }) - .then((trackFrames) => { - if (!trackFrames.length) { - return; - } - const frameUpdates = { trackFrames }; - setUserAssets((assets) => - assets.map((item) => - item.id === asset.id ? { ...item, ...frameUpdates } : item, - ), - ); - updateVisualAssetInTimeline(asset.id, frameUpdates); - }) - .catch((error) => { - console.warn("Video timeline frame extraction failed", error); - }); - }; - video.onerror = () => { - setUserAssets((assets) => - assets.map((item) => (item.id === asset.id ? { ...item, meta: "视频读取失败" } : item)), - ); - }; - video.src = asset.src; - return; - } - - const image = new Image(); - image.onload = () => { - const width = image.naturalWidth || 0; - const height = image.naturalHeight || 0; - const meta = `${width} x ${height}`; - const updates = { meta, width, height, type: "image" }; - setUserAssets((assets) => - assets.map((item) => (item.id === asset.id ? { ...item, ...updates } : item)), - ); - updateVisualAssetInTimeline(asset.id, updates); - }; - image.onerror = () => { - setUserAssets((assets) => - assets.map((item) => (item.id === asset.id ? { ...item, meta: "读取失败" } : item)), - ); - }; - image.src = asset.src; - }); - - notify( - mediaFiles.length > 1 - ? `已上传 ${mediaFiles.length} 个素材,拖到对应轨道后使用` - : `${primaryAsset.type === "audio" ? "音频" : primaryAsset.type === "video" ? "视频" : "图片"}已上传到素材库,请拖到轨道使用`, - ); - } - - function pauseTimelineMedia() { - audioSegmentRefs.current.forEach((audio) => audio.pause()); - sourceAudioRef.current?.pause(); - musicRef.current?.pause(); - previewVideoRef.current?.pause(); - } - - function handlePlayToggle() { - const previewVideo = previewVideoRef.current; - const voiceAudios = trackVisibility.audio - ? audioSegments.map((segment) => ({ segment, audio: audioSegmentRefs.current.get(segment.id) })).filter((item) => item.audio) - : []; - const sourceAudio = trackVisibility.source ? sourceAudioRef.current : null; - const musicAudio = trackVisibility.music ? musicRef.current : null; - const currentTimelineTime = currentTimeRef.current; - const syncVoiceAudio = ({ segment, audio }) => { - const timelineTime = currentTimeRef.current; - const active = isTimelineTimeInsideTrack(timelineTime, segment.start, segment.duration); - audio.currentTime = getTimelineTrackLocalTime(timelineTime, segment.start, segment.duration); - audio.volume = getAudioSegmentPreviewVolume(segment, timelineTime); - audio.playbackRate = 1; - return active; - }; - const syncSourceAudioTime = () => { - if (!sourceAudio || !sourceAudioUrl) { - return false; - } - const timelineTime = currentTimeRef.current; - const localTime = getTimelineTrackLocalTime(timelineTime, sourceAudioStart, sourceAudioDuration); - sourceAudio.currentTime = localTime; - return isTimelineTimeInsideTrack(timelineTime, sourceAudioStart, sourceAudioDuration); - }; - const syncMusicTime = () => { - if (!musicAudio || !musicUrl) { - return false; - } - const timelineTime = currentTimeRef.current; - musicAudio.currentTime = Math.max(0, Math.min(musicDuration || timelineTime, timelineTime)); - return timelineTime <= (musicDuration || timelineTime); - }; - const syncPreviewVideoTime = () => { - if (previewVisualType !== "video" || !previewVideo) { - return false; - } - const timelineTime = currentTimeRef.current; - const visualIndex = getVisualSegmentIndexAtTime(visualSegments, timelineTime); - const visualRange = visualTimeline[Math.max(0, visualIndex)] ?? currentVisualRange; - const localTime = visualRange ? Math.max(0, timelineTime - visualRange.start) : timelineTime; - previewVideo.currentTime = Math.min(localTime, previewVideo.duration || localTime); - return true; - }; - const playIfReady = (media, ready) => { - if (ready) { - media?.play().catch(() => {}); - } else { - media?.pause(); - } - }; - - if (isPlaying) { - pauseTimelineMedia(); - setIsPlaying(false); - return; - } - - if (!canPreview) { - notify("请先上传图片/视频素材、生成配音或上传背景音乐"); - return; - } - - if (currentTimelineTime >= estimatedDuration - 0.02) { - seekTo(0); - currentTimeRef.current = 0; - } - - const nextTime = currentTimeRef.current; - if (nextTime !== currentTimelineTime) { - voiceAudios.forEach(({ audio }) => { audio.currentTime = 0; }); - if (sourceAudio && sourceAudioUrl) { - const localTime = getTimelineTrackLocalTime(nextTime, sourceAudioStart, sourceAudioDuration); - sourceAudio.currentTime = localTime; - } - if (musicAudio && musicUrl) { - musicAudio.currentTime = 0; - } - if (previewVideo && previewVisualType === "video") { - previewVideo.currentTime = 0; - } - } - - voiceAudios.forEach((item) => playIfReady(item.audio, syncVoiceAudio(item))); - playIfReady(sourceAudio, syncSourceAudioTime()); - playIfReady(musicAudio, syncMusicTime()); - playIfReady(previewVideo, syncPreviewVideoTime()); - setIsPlaying(true); - } - - function seekTo(nextTime) { - const clamped = Math.max(0, Math.min(timelineDurationRef.current || MAX_TIMELINE_DURATION_SECONDS, nextTime)); - currentTimeRef.current = clamped; - setCurrentTime(clamped); - audioSegments.forEach((segment) => { - const audio = audioSegmentRefs.current.get(segment.id); - if (audio) audio.currentTime = getTimelineTrackLocalTime(clamped, segment.start, segment.duration); - }); - if (sourceAudioRef.current) { - sourceAudioRef.current.currentTime = getTimelineTrackLocalTime( - clamped, - sourceAudioStart, - sourceAudioDuration, - ); - } - if (musicRef.current) { - musicRef.current.currentTime = clamped; - } - } - - function getTimelineTimeFromClientX(clientX) { - const rect = trackScrollRef.current?.getBoundingClientRect(); - const duration = timelineDurationRef.current; - if (!rect || duration <= 0) { - return 0; - } - const ratioAtPointer = (clientX - rect.left) / Math.max(rect.width, 1); - return Math.max(0, Math.min(duration, ratioAtPointer * duration)); - } - - function startTimelineSeek(event) { - if (event.button !== 0 || timelineDuration <= 0) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - seekTo(getTimelineTimeFromClientX(event.clientX)); - - const handlePointerMove = (moveEvent) => { - seekTo(getTimelineTimeFromClientX(moveEvent.clientX)); - }; - const handlePointerUp = () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - }; - - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", handlePointerUp, { once: true }); - } - - function startAudioSegmentMove(event, segmentId = "") { - if (event.button !== 0) return; - const segment = audioSegments.find((item) => item.id === segmentId); - if (!segment) return; - if (trackLocks.audio) { - notify(t("audioTrackLockedMove")); - return; - } - const rect = trackScrollRef.current?.getBoundingClientRect(); - const duration = timelineDurationRef.current || 10; - if (!rect) return; - event.stopPropagation(); - setSelectedTrack("audio"); - setSelectedAudioSegmentId(segment.id); - const startX = event.clientX; - const startTime = segment.start || 0; - const linkedCaptions = captionSegments.filter((caption) => caption.audioSegmentId === segment.id); - let moved = false; - let latestStart = startTime; - const handlePointerMove = (moveEvent) => { - if (!moved && Math.abs(moveEvent.clientX - startX) < 4) return; - moved = true; - moveEvent.preventDefault(); - const delta = ((moveEvent.clientX - startX) / Math.max(rect.width, 1)) * duration; - latestStart = Math.max(0, Math.min(MAX_TIMELINE_DURATION_SECONDS - segment.duration, startTime + delta)); - setAudioSegments((segments) => segments.map((item) => item.id === segment.id ? { ...item, start: latestStart } : item)); - const captionDelta = latestStart - startTime; - setCaptionSegments((captions) => captions.map((caption) => { - const original = linkedCaptions.find((item) => item.id === caption.id); - return original ? { ...caption, start: original.start + captionDelta, end: original.end + captionDelta } : caption; - })); - setTimelineHorizon((value) => Math.max(value, Math.ceil((latestStart + segment.duration + 5) / 10) * 10)); - }; - const cleanup = () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - window.removeEventListener("pointercancel", cleanup); - }; - const handlePointerUp = () => { - cleanup(); - if (moved) { - seekTo(latestStart); - notify(t("audioClipMoved")); - } - }; - window.addEventListener("pointermove", handlePointerMove, { passive: false }); - window.addEventListener("pointerup", handlePointerUp); - window.addEventListener("pointercancel", cleanup); - } - - function startStickerSegmentMove(event, segmentId = "") { - if (event.button !== 0) { - return; - } - - const segment = stickerSegments.find((item) => item.id === segmentId); - if (!segment) { - return; - } - - if (trackLocks.sticker) { - notify("贴纸轨已锁定,无法移动贴纸"); - return; - } - - const rect = trackScrollRef.current?.getBoundingClientRect(); - const duration = timelineDurationRef.current || Math.max(estimatedDuration, segment.start + segment.duration, 10); - if (!rect || duration <= 0) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - setSelectedTrack("sticker"); - setActiveTool("stickers"); - setSelectedStickerSegmentId(segment.id); - if (segment.stickerId) { - setSelectedStickerId(segment.stickerId); - } - - const startX = event.clientX; - const startY = event.clientY; - const startTime = segment.start || 0; - const segmentDuration = Math.max(MIN_VISUAL_SEGMENT_SECONDS, segment.duration || DEFAULT_STICKER_SEGMENT_SECONDS); - let moved = false; - let latestStart = startTime; - - const getNextStart = (clientX) => { - const deltaSeconds = ((clientX - startX) / Math.max(rect.width, 1)) * duration; - return Math.max(0, Math.min(MAX_TIMELINE_DURATION_SECONDS - segmentDuration, startTime + deltaSeconds)); - }; - - const applyNextStart = (clientX) => { - latestStart = getNextStart(clientX); - setStickerSegments((segments) => - segments.map((item) => (item.id === segment.id ? { ...item, start: latestStart } : item)), - ); - }; - - const cleanup = () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - window.removeEventListener("pointercancel", handlePointerCancel); - }; - - const handlePointerMove = (moveEvent) => { - const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY); - if (!moved && distance < 4) { - return; - } - - moved = true; - moveEvent.preventDefault(); - applyNextStart(moveEvent.clientX); - }; - - const handlePointerUp = () => { - cleanup(); - if (!moved) { - return; - } - - suppressTimelineClipClickRef.current = segment.id; - window.setTimeout(() => { - if (suppressTimelineClipClickRef.current === segment.id) { - suppressTimelineClipClickRef.current = ""; - } - }, 160); - seekTo(latestStart); - notify("贴纸片段位置已调整"); - }; - - const handlePointerCancel = () => { - cleanup(); - }; - - window.addEventListener("pointermove", handlePointerMove, { passive: false }); - window.addEventListener("pointerup", handlePointerUp); - window.addEventListener("pointercancel", handlePointerCancel); - } - - function startImageResize(event, segmentId = "", segmentIndex = -1) { - if (event.button !== 0) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - if (trackLocks.image) { - notify("图片轨已锁定,无法拉长片段"); - return; - } - - if (!imageSrc || timelineDuration <= 0) { - notify("请先上传或选择图片/视频素材"); - return; - } - - setSelectedTrack("image"); - - const rect = trackScrollRef.current?.getBoundingClientRect(); - const startSegments = visualSegments.length - ? visualSegments - : [createVisualSegment(imageDuration || 4, getCurrentVisualAssetSnapshot())]; - const segmentIdIndex = startSegments.findIndex((segment) => segment.id === segmentId); - const resizeSegmentIndex = - segmentIdIndex >= 0 - ? segmentIdIndex - : segmentIndex >= 0 && segmentIndex < startSegments.length - ? segmentIndex - : Math.max(0, startSegments.length - 1); - const resizeSegmentId = startSegments[resizeSegmentIndex]?.id ?? ""; - const durationBeforeResizeSegment = getVisualSegmentsTotal( - startSegments.slice(0, resizeSegmentIndex), - ); - const durationAfterResizeSegment = getVisualSegmentsTotal( - startSegments.slice(resizeSegmentIndex + 1), - ); - const startDuration = Math.max(MIN_VISUAL_SEGMENT_SECONDS, imageDuration); - const startTimelineDuration = Math.max( - 10, - startDuration, - timelineDurationRef.current || timelineDuration, - ); - setSelectedVisualSegmentId(resizeSegmentId); - const secondsPerPixel = rect - ? startTimelineDuration / Math.max(rect.width, 1) - : IMAGE_RESIZE_OVERFLOW_SECONDS_PER_PIXEL; - const overflowSecondsPerPixel = Math.max( - secondsPerPixel, - IMAGE_RESIZE_OVERFLOW_SECONDS_PER_PIXEL, - ); - const audioSnapPoint = - audioBlob && audioDuration > 0 - ? { - time: Math.min(MAX_TIMELINE_DURATION_SECONDS, audioDuration), - label: "配音结尾", - } - : null; - const sourceSnapPoint = - sourceAudioBlob && sourceAudioDuration > 0 - ? { - time: Math.min(MAX_TIMELINE_DURATION_SECONDS, sourceAudioStart + sourceAudioDuration), - label: "原声结尾", - } - : null; - const musicSnapPoint = - musicBlob && musicDuration > 0 - ? { - time: Math.min(MAX_TIMELINE_DURATION_SECONDS, musicDuration), - label: "音乐结尾", - } - : null; - let snappedToAudio = false; - let snappedToSource = false; - let snappedToMusic = false; - - const applyDurationFromPointer = (clientX) => { - if (!rect) { - return; - } - - const pointerX = clientX - rect.left; - const inTrackX = Math.max(0, Math.min(rect.width, pointerX)); - const overflowX = Math.max(0, pointerX - rect.width); - const nextDuration = - (inTrackX / Math.max(rect.width, 1)) * startTimelineDuration + - overflowX * overflowSecondsPerPixel; - const clampedDuration = Math.max( - 0.5, - Math.min(MAX_TIMELINE_DURATION_SECONDS, nextDuration), - ); - const snapCandidates = [audioSnapPoint, sourceSnapPoint, musicSnapPoint] - .filter(Boolean) - .map((point) => ({ - ...point, - distance: Math.abs(pointerX - (point.time / startTimelineDuration) * rect.width), - })) - .filter((point) => point.distance <= IMAGE_SNAP_THRESHOLD_PIXELS) - .sort((a, b) => a.distance - b.distance); - const activeSnapPoint = snapCandidates[0] ?? null; - const snappedDuration = activeSnapPoint ? activeSnapPoint.time : clampedDuration; - const maxResizeSegmentDuration = Math.max( - MIN_VISUAL_SEGMENT_SECONDS, - MAX_TIMELINE_DURATION_SECONDS - durationBeforeResizeSegment - durationAfterResizeSegment, - ); - const resizeSegmentDuration = Math.min( - maxResizeSegmentDuration, - Math.max(MIN_VISUAL_SEGMENT_SECONDS, snappedDuration - durationBeforeResizeSegment), - ); - const nextSegments = startSegments.map((segment, index) => - index === resizeSegmentIndex ? { ...segment, duration: resizeSegmentDuration } : segment, - ); - const nextVisualDuration = getVisualSegmentsTotal(nextSegments); - const nextProjectDuration = Math.max( - audioBlob ? audioDuration : 0, - captionDuration, - sourceAudioBlob ? sourceAudioStart + sourceAudioDuration : 0, - musicBlob ? musicDuration : 0, - estimateDuration(script), - nextVisualDuration, - ); - snappedToAudio = activeSnapPoint?.label === "配音结尾"; - snappedToSource = activeSnapPoint?.label === "原声结尾"; - snappedToMusic = activeSnapPoint?.label === "音乐结尾"; - setSnapGuide(activeSnapPoint); - setVisualSegments(nextSegments); - setImageDuration(nextVisualDuration); - setImageClipCount(getImageThumbnailCount(nextVisualDuration)); - setCurrentTime((time) => Math.min(time, nextProjectDuration)); - }; - - applyDurationFromPointer(event.clientX); - - const handlePointerMove = (moveEvent) => { - applyDurationFromPointer(moveEvent.clientX); - }; - const handlePointerUp = () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - setSnapGuide(null); - notify( - snappedToAudio - ? "图片已吸附到配音结尾" - : snappedToSource - ? "图片已吸附到视频原声结尾" - : snappedToMusic - ? "图片已吸附到音乐结尾" - : "图片片段时长已调整", - ); - }; - - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", handlePointerUp, { once: true }); - } - - async function handleExportVideo() { - if (exporting) { - return; - } - - if (!imageSrc) { - notify("请先上传或选择图片/视频素材再导出"); - return; - } - - setExporting(true); - exportStartRef.current = performance.now(); - setExportElapsedSeconds(0); - setExportProgress(1); - setExportPhase("准备导出"); - setStatus("generating"); - const preferredFormat = getSupportedRecordingFormat(); - setStatusText(`录制 ${preferredFormat.label} 视频流`); - setExportPhase(`录制 ${preferredFormat.label} 视频流`); - const updateExportProgress = ({ progress: nextProgress, phase }) => { - setExportProgress((currentProgress) => - Math.max(currentProgress, Math.min(100, Math.max(0, Math.round(nextProgress)))), - ); - if (phase) { - setExportPhase(phase); - } - }; - const finishExportProgress = async (phase) => { - setExportPhase(phase); - setExportProgress(100); - await new Promise((resolve) => { - window.setTimeout(resolve, 450); - }); - }; - - try { - const video = await exportBrowserVideo({ - imageSrc, - visualType, - visualSegments: renderedVisualSegments.map((segment) => { - const record = visionRecords[getVisionKey(segment)]; - return record - ? { - ...segment, - vision: { - ...record.analysis, - options: record.options, - }, - } - : segment; - }), - audioBlob: null, - voiceAudioSegments: trackVisibility.audio ? audioSegments : [], - voiceVolume: volume, - sourceAudioBlob: trackVisibility.source ? sourceAudioBlob : null, - sourceAudioVolume, - sourceAudioStart, - musicBlob: trackVisibility.music ? musicBlob : null, - musicVolume, - text: script, - captionSegments, - duration: Math.max( - trackVisibility.audio ? voiceTrackDuration : 0, - captionDuration, - trackVisibility.source && sourceAudioBlob ? sourceAudioStart + sourceAudioDuration : 0, - trackVisibility.music && musicBlob ? musicDuration : 0, - trackVisibility.sticker ? stickerDuration : 0, - imageDuration, - estimateDuration(script), - ), - ratio, - fitMode, - filter: selectedFilter.css, - captionsEnabled: captionsEnabled && trackVisibility.caption, - captionPosition, - captionPlacement, - captionSize, - captionStyle, - captionReferenceSize: - previewFrameSize.width > 0 && previewFrameSize.height > 0 - ? previewFrameSize - : { - width: (360 * ratio.width) / ratio.height, - height: 360, - }, - sticker: stickerSegments.length ? null : selectedSticker, - stickerSegments: trackVisibility.sticker ? stickerSegments : [], - transitionId: selectedTransitionId, - onProgress: updateExportProgress, - }); - - if (video.nativeMp4) { - updateExportProgress({ progress: 98, phase: "保存 MP4 文件" }); - downloadBlob(video.blob, `ai-voiceover-${ratio.id.replace(":", "x")}.mp4`); - setStatus("done"); - setStatusText("MP4 已导出"); - await finishExportProgress("导出完成"); - notify("已用浏览器原生 MP4 快速导出"); - return; - } - - setStatusText("当前浏览器不支持原生 MP4,加载 FFmpeg WASM"); - updateExportProgress({ progress: 95, phase: "加载 FFmpeg 转码器" }); - - try { - setStatusText("转码 MP4"); - updateExportProgress({ progress: 96, phase: "转码 MP4" }); - const mp4 = await transcodeWebmToMp4(video.blob); - updateExportProgress({ progress: 99, phase: "保存 MP4 文件" }); - downloadBlob(mp4, `ai-voiceover-${ratio.id.replace(":", "x")}.mp4`); - setStatus("done"); - setStatusText("MP4 已导出"); - await finishExportProgress("导出完成"); - notify("MP4 视频已导出"); - } catch (ffmpegError) { - console.error(ffmpegError); - updateExportProgress({ progress: 99, phase: "保存 WebM 兜底文件" }); - downloadBlob(video.blob, `ai-voiceover-${ratio.id.replace(":", "x")}.webm`); - setStatus("done"); - setStatusText("WebM 兜底已导出"); - await finishExportProgress("WebM 兜底已导出"); - notify("MP4 转码失败,已导出 WebM 兜底"); - } - } catch (error) { - console.error(error); - setStatus("error"); - setStatusText(error instanceof Error ? error.message : "视频导出失败"); - setExportPhase("导出失败"); - } finally { - setExporting(false); - setExportProgress(0); - } - } - - function getFocusedStickerSegmentIndex() { - if (!stickerSegments.length) { - return -1; - } - - if (selectedStickerSegmentId) { - const selectedIndex = stickerSegments.findIndex((segment) => segment.id === selectedStickerSegmentId); - if (selectedIndex >= 0) { - return selectedIndex; - } - } - - return currentStickerSegmentIndex >= 0 ? currentStickerSegmentIndex : 0; - } - - function deleteCurrentStickerSegment() { - if (trackLocks.sticker) { - notify("贴纸轨已锁定,无法删除"); - return; - } - - const index = getFocusedStickerSegmentIndex(); - if (index < 0) { - notify("当前没有贴纸片段可删除"); - return; - } - - const nextSegments = stickerSegments.filter((_, segmentIndex) => segmentIndex !== index); - commitStickerSegments( - nextSegments, - nextSegments.length ? "已删除当前贴纸片段" : "已删除最后一个贴纸片段", - nextSegments[Math.max(0, index - 1)]?.id ?? "", - ); - } - - function handleDeleteTrack() { - if (trackLocks[selectedTrack]) { - notify("当前轨道已锁定,无法删除"); - return; - } - - if (selectedTrack === "caption") { - handleRemoveSegment(); - return; - } - - if (selectedTrack === "sticker") { - deleteCurrentStickerSegment(); - return; - } - - if (selectedTrack === "image") { - if (!imageSrc || imageClipCount === 0) { - notify("当前没有视觉片段可删除"); - return; - } - - const sourceSegments = visualSegments.length - ? visualSegments - : [createVisualSegment(imageDuration || 0, getCurrentVisualAssetSnapshot())]; - const index = - selectedVisualSegmentId && sourceSegments.some((segment) => segment.id === selectedVisualSegmentId) - ? selectedVisualSegmentIndex - : currentVisualSegmentIndex >= 0 - ? currentVisualSegmentIndex - : 0; - const nextSegments = sourceSegments.filter((_, segmentIndex) => segmentIndex !== index); - if (nextSegments.length) { - commitVisualSegments(nextSegments, "已删除当前视觉片段", Math.max(0, index - 1)); - } else { - clearImageTrack("已删除当前视觉片段"); - } - return; - } - - if (selectedTrack === "audio") { - const segmentId = selectedAudioSegmentId || selectedAudioSegment?.id; - if (!segmentId) { - notify("当前没有选中的配音片段"); - return; - } - deleteAudioSegment(segmentId); - return; - } - - if (selectedTrack === "source") { - clearSourceAudioTrack(); - return; - } - - if (selectedTrack === "music") { - clearMusicTrack(); - return; - } - - clearImageTrack(); - } - - function handleDuplicateTrack() { - if (selectedTrack === "sticker") { - if (!stickerSegments.length) { - notify("当前没有可复制的贴纸片段"); - return; - } - const index = getFocusedStickerSegmentIndex(); - const source = stickerSegments[index]; - if (!source) { - notify("请先选择一个贴纸片段"); - return; - } - const nextSegment = { - ...source, - id: makeId("sticker"), - start: Math.min( - MAX_TIMELINE_DURATION_SECONDS - source.duration, - source.start + source.duration + 0.2, - ), - }; - commitStickerSegments([...stickerSegments, nextSegment], "已复制当前贴纸片段", nextSegment.id); - return; - } - - if (selectedTrack === "caption") { - if (!captionSegments.length) { - notify("当前没有可复制的字幕片段"); - return; - } - const index = selectedSegmentId ? selectedSegmentIndex : focusedSegmentIndex; - const source = captionSegments[index] ?? captionSegments[focusedSegmentIndex]; - const nextSegments = [...captionSegments]; - nextSegments.splice(index + 1, 0, { - ...source, - id: makeId("caption"), - text: `${source.text} 副本`, - }); - commitCaptionSegments(nextSegments, "已复制当前字幕片段", index + 1); - return; - } - - if (selectedTrack === "image") { - if (!imageSrc) { - notify("当前没有可复制的图片素材"); - return; - } - const sourceSegments = visualSegments.length - ? visualSegments - : [createVisualSegment(imageDuration || 4, getCurrentVisualAssetSnapshot())]; - const selectedSegment = sourceSegments[ - selectedVisualSegmentId && sourceSegments.some((segment) => segment.id === selectedVisualSegmentId) - ? selectedVisualSegmentIndex - : Math.max(0, currentVisualSegmentIndex) - ] ?? getCurrentVisualAssetSnapshot(); - setUserAssets((assets) => [ - { - id: crypto.randomUUID(), - type: selectedSegment.type || visualType, - src: selectedSegment.src || imageSrc, - name: `${(selectedSegment.name || imageName).replace(/\.[^.]+$/, "")}-copy.${ - (selectedSegment.type || visualType) === "video" ? "mp4" : "png" - }`, - meta: selectedSegment.meta || imageMeta, - duration: selectedSegment.duration || imageDuration, - blob: selectedSegment.blob || null, - }, - ...assets, - ]); - notify("当前图片已复制到我的素材"); - return; - } - - if (selectedTrack === "audio") { - if (!selectedAudioSegment) { - notify(t("audioClipMissing")); - return; - } - const id = crypto.randomUUID(); - const start = Math.min(MAX_TIMELINE_DURATION_SECONDS - selectedAudioSegment.duration, selectedAudioSegment.start + 0.2); - const copy = { - ...selectedAudioSegment, - id, - url: URL.createObjectURL(selectedAudioSegment.blob), - start, - name: `${selectedAudioSegment.name || t("audioClip")} ${t("copySuffix")}`, - }; - const delta = start - selectedAudioSegment.start; - const copiedCaptions = captionSegments - .filter((caption) => caption.audioSegmentId === selectedAudioSegment.id) - .map((caption) => ({ ...caption, id: makeId("caption"), audioSegmentId: id, start: caption.start + delta, end: caption.end + delta })); - setAudioSegments((segments) => [...segments, copy]); - setCaptionSegments((segments) => [...segments, ...copiedCaptions].sort((a, b) => (a.start || 0) - (b.start || 0))); - setSelectedAudioSegmentId(id); - notify(t("audioClipDuplicated")); - return; - } - - if (selectedTrack === "music") { - if (musicBlob) { - downloadBlob(musicBlob, musicName || "background-music.wav"); - notify("背景音乐副本已下载"); - } else { - notify("当前没有背景音乐"); - } - return; - } - - if (selectedTrack === "source") { - if (sourceAudioBlob) { - downloadBlob(sourceAudioBlob, sourceAudioName || "source-audio.wav"); - notify("视频原声副本已下载"); - } else { - notify("当前没有视频原声"); - } - return; - } - - if (audioBlob) { - downloadBlob(audioBlob, "ai-voiceover-copy.wav"); - notify("当前音频副本已下载"); - } else { - notify("当前没有可复制的音频"); - } - } - - function handleCutVisualSegment() { - if (trackLocks.image) { - notify("图片轨已锁定,无法剪切"); - return; - } - - if (!imageSrc) { - notify("请先上传或选择图片/视频素材"); - return; - } - - const sourceSegments = visualSegments.length - ? visualSegments - : [createVisualSegment(imageDuration || 0, getCurrentVisualAssetSnapshot())]; - const totalDuration = getVisualSegmentsTotal(sourceSegments); - if (totalDuration < MIN_VISUAL_SEGMENT_SECONDS * 2) { - notify("当前视觉片段太短,不适合继续剪切"); - return; - } - - const splitTime = Math.max(0, Math.min(totalDuration, currentTime)); - if ( - splitTime <= MIN_VISUAL_SEGMENT_SECONDS || - splitTime >= totalDuration - MIN_VISUAL_SEGMENT_SECONDS - ) { - notify("请把播放头放在视觉片段中间再剪切"); - return; - } - - const timeline = getVisualSegmentTimeline(sourceSegments); - const segmentIndex = timeline.findIndex( - (segment) => splitTime > segment.start && splitTime < segment.end, - ); - const segmentRange = timeline[segmentIndex]; - const source = sourceSegments[segmentIndex]; - if (!source || !segmentRange) { - notify("请先选中要剪切的视觉片段"); - return; - } - - const firstDuration = splitTime - segmentRange.start; - const secondDuration = segmentRange.end - splitTime; - if ( - firstDuration < MIN_VISUAL_SEGMENT_SECONDS || - secondDuration < MIN_VISUAL_SEGMENT_SECONDS - ) { - notify("切点离片段边缘太近,先把播放头移到片段中间"); - return; - } - - const firstSegment = { - ...source, - id: makeId("visual"), - duration: firstDuration, - }; - const secondSegment = { - ...source, - id: makeId("visual"), - duration: secondDuration, - sourceStart: - source.type === "video" - ? Math.max(0, Number(source.sourceStart) || 0) + firstDuration - : Math.max(0, Number(source.sourceStart) || 0), - }; - const nextSegments = [...sourceSegments]; - nextSegments.splice(segmentIndex, 1, firstSegment, secondSegment); - commitVisualSegments(nextSegments, "已在播放头位置切开视觉片段", segmentIndex + 1); - } - - function handleCutCaption() { - if (trackLocks.caption) { - notify("字幕轨已锁定,无法剪切"); - return; - } - const index = selectedSegmentId ? selectedSegmentIndex : focusedSegmentIndex; - const source = captionSegments[index]; - - if (!source || source.text.length < 6) { - notify("当前字幕太短,不适合继续拆分"); - return; - } - - const splitAt = Math.max(2, Math.ceil(source.text.length / 2)); - const splitTime = - hasExplicitCaptionTiming(source) && source.end - source.start > 0.4 - ? source.start + (source.end - source.start) / 2 - : null; - const nextSegments = [...captionSegments]; - nextSegments.splice( - index, - 1, - { - ...source, - id: makeId("caption"), - text: source.text.slice(0, splitAt), - weight: Math.max(0.7, (source.weight ?? 1) / 2), - ...(splitTime ? { end: splitTime } : {}), - }, - { - ...source, - id: makeId("caption"), - text: source.text.slice(splitAt), - weight: Math.max(0.7, (source.weight ?? 1) / 2), - ...(splitTime ? { start: splitTime } : {}), - }, - ); - commitCaptionSegments(nextSegments, "已把当前字幕片段拆成两段", index + 1); - } - - function handleCutTrack() { - if (selectedTrack === "sticker") { - if (trackLocks.sticker) { - notify("贴纸轨已锁定,无法剪切"); - return; - } - const index = getFocusedStickerSegmentIndex(); - const source = stickerSegments[index]; - if (!source) { - notify("请先选择一个贴纸片段"); - return; - } - const splitTime = Math.max(source.start, Math.min(source.start + source.duration, currentTime)); - if (splitTime <= source.start + 0.35 || splitTime >= source.start + source.duration - 0.35) { - notify("请把播放头放在贴纸片段中间再剪切"); - return; - } - const firstSegment = { ...source, id: makeId("sticker"), duration: splitTime - source.start }; - const secondSegment = { - ...source, - id: makeId("sticker"), - start: splitTime, - duration: source.start + source.duration - splitTime, - }; - const nextSegments = [...stickerSegments]; - nextSegments.splice(index, 1, firstSegment, secondSegment); - commitStickerSegments(nextSegments, "已在播放头位置切开贴纸片段", secondSegment.id); - return; - } - - if (selectedTrack === "image") { - handleCutVisualSegment(); - return; - } - - if (selectedTrack === "caption") { - handleCutCaption(); - return; - } - - notify("当前轨道暂不支持剪切片段"); - } - - function handleAddSegment() { - if (selectedTrack === "sticker") { - if (trackLocks.sticker) { - notify("贴纸轨已锁定,无法新增贴纸片段"); - return; - } - const source = - stickerSegments[getFocusedStickerSegmentIndex()] ?? - getStickerDragAsset(selectedSticker); - if (!source?.src) { - notify("请先选择一个贴纸"); - return; - } - const nextSegment = createStickerSegment(source, currentTime, DEFAULT_STICKER_SEGMENT_SECONDS); - commitStickerSegments([...stickerSegments, nextSegment], "已新增贴纸片段", nextSegment.id); - return; - } - - if (selectedTrack === "image") { - if (trackLocks.image) { - notify("图片轨已锁定,无法新增片段"); - return; - } - if (!imageSrc) { - notify("请先上传或选择图片/视频素材"); - return; - } - const sourceSegments = visualSegments.length - ? visualSegments - : [createVisualSegment(imageDuration || 4, getCurrentVisualAssetSnapshot())]; - const totalDuration = getVisualSegmentsTotal(sourceSegments); - const availableDuration = MAX_TIMELINE_DURATION_SECONDS - totalDuration; - if (availableDuration < MIN_VISUAL_SEGMENT_SECONDS) { - notify("视觉轨道已经达到 30 分钟上限"); - return; - } - const sourceAsset = - sourceSegments[ - selectedVisualSegmentId && sourceSegments.some((segment) => segment.id === selectedVisualSegmentId) - ? selectedVisualSegmentIndex - : Math.max(0, sourceSegments.length - 1) - ] ?? getCurrentVisualAssetSnapshot(); - const nextSegment = createVisualSegment(Math.min(IMAGE_SEGMENT_SECONDS, availableDuration), sourceAsset); - commitVisualSegments( - [...sourceSegments, nextSegment], - "已新增一个视觉片段", - sourceSegments.length, - ); - return; - } - - if (selectedTrack === "audio" || selectedTrack === "source" || selectedTrack === "music") { - notify( - selectedTrack === "music" - ? "背景音乐暂不支持切片,请删除后重新上传" - : selectedTrack === "source" - ? "视频原声暂不支持切片,可删除后重新上传视频" - : "音频片段由生成结果决定,请重新生成或复制 WAV", - ); - return; - } - - const index = selectedSegmentId ? selectedSegmentIndex : focusedSegmentIndex; - const previousSegment = captionSegments[index]; - const nextSegment = captionSegments[index + 1]; - const timedInsertStart = hasExplicitCaptionTiming(previousSegment) - ? previousSegment.end - : null; - const timedInsertEnd = - timedInsertStart !== null && hasExplicitCaptionTiming(nextSegment) && nextSegment.start - timedInsertStart > 0.45 - ? nextSegment.start - : timedInsertStart !== null - ? Math.min(MAX_TIMELINE_DURATION_SECONDS, timedInsertStart + 1.8) - : null; - const nextSegments = [...captionSegments]; - nextSegments.splice(captionSegments.length ? index + 1 : 0, 0, { - id: makeId("caption"), - text: "新的字幕片段", - weight: captionSegments[index]?.weight ?? 1, - hidden: false, - ...(timedInsertStart !== null && timedInsertEnd !== null - ? { start: timedInsertStart, end: Math.max(timedInsertStart + 0.45, timedInsertEnd) } - : {}), - }); - commitCaptionSegments(nextSegments, "已新增字幕片段", index + 1); - } - - function handleRemoveSegment() { - if (selectedTrack === "sticker") { - deleteCurrentStickerSegment(); - return; - } - - if (selectedTrack === "image") { - if (trackLocks.image) { - notify("图片轨已锁定,无法减少片段"); - return; - } - if (!imageSrc || imageClipCount === 0) { - notify("当前没有视觉片段可减少"); - return; - } - const sourceSegments = visualSegments.length - ? visualSegments - : [createVisualSegment(imageDuration || 0, getCurrentVisualAssetSnapshot())]; - if (sourceSegments.length > 1) { - const index = - selectedVisualSegmentId && sourceSegments.some((segment) => segment.id === selectedVisualSegmentId) - ? selectedVisualSegmentIndex - : currentVisualSegmentIndex >= 0 - ? currentVisualSegmentIndex - : sourceSegments.length - 1; - const nextSegments = sourceSegments.filter((_, segmentIndex) => segmentIndex !== index); - commitVisualSegments(nextSegments, "已删除当前视觉片段", Math.max(0, index - 1)); - return; - } - - if (sourceSegments[0].duration <= IMAGE_SEGMENT_SECONDS) { - clearImageTrack("已删除最后一个视觉片段"); - return; - } - commitVisualSegments( - [{ ...sourceSegments[0], duration: sourceSegments[0].duration - IMAGE_SEGMENT_SECONDS }], - "已缩短当前视觉片段", - 0, - ); - return; - } - - if (selectedTrack === "audio" || selectedTrack === "source" || selectedTrack === "music") { - notify( - selectedTrack === "music" - ? "背景音乐可整轨删除,暂不支持局部减少" - : selectedTrack === "source" - ? "视频原声可整轨删除,暂不支持局部减少" - : "音频片段不能单独减少;可以删除音频轨或重新生成", - ); - return; - } - - if (!captionSegments.length) { - notify("当前没有字幕片段可删除"); - return; - } - - deleteCaptionSegment(selectedSegmentId); - } - - function adjustSelectedSegmentWeight(delta) { - if (selectedTrack === "sticker") { - if (trackLocks.sticker) { - notify("贴纸轨已锁定,无法调整片段长度"); - return; - } - const index = getFocusedStickerSegmentIndex(); - const source = stickerSegments[index]; - if (!source) { - notify("请先选择一个贴纸片段"); - return; - } - const nextDuration = Math.max( - MIN_VISUAL_SEGMENT_SECONDS, - Math.min(MAX_TIMELINE_DURATION_SECONDS - source.start, source.duration + (delta > 0 ? 0.5 : -0.5)), - ); - if (Math.abs(nextDuration - source.duration) < 0.001) { - notify(delta > 0 ? "当前贴纸片段已到最大长度" : "当前贴纸片段已到最短时长"); - return; - } - const nextSegments = stickerSegments.map((segment, segmentIndex) => - segmentIndex === index ? { ...segment, duration: nextDuration } : segment, - ); - commitStickerSegments(nextSegments, delta > 0 ? "当前贴纸片段已加长" : "当前贴纸片段已缩短", source.id); - return; - } - - if (selectedTrack === "image") { - if (trackLocks.image) { - notify("图片轨已锁定,无法调整片段长度"); - return; - } - - if (!imageSrc) { - notify("请先上传或选择图片/视频素材"); - return; - } - - const secondsDelta = delta > 0 ? 1 : -1; - const sourceSegments = visualSegments.length - ? visualSegments - : [createVisualSegment(imageDuration || 4, getCurrentVisualAssetSnapshot())]; - const index = - selectedVisualSegmentId && sourceSegments.some((segment) => segment.id === selectedVisualSegmentId) - ? selectedVisualSegmentIndex - : currentVisualSegmentIndex >= 0 - ? currentVisualSegmentIndex - : sourceSegments.length - 1; - const targetSegment = sourceSegments[index]; - const durationWithoutTarget = getVisualSegmentsTotal(sourceSegments) - targetSegment.duration; - const maxTargetDuration = Math.max( - MIN_VISUAL_SEGMENT_SECONDS, - MAX_TIMELINE_DURATION_SECONDS - durationWithoutTarget, - ); - const nextTargetDuration = Math.min( - maxTargetDuration, - Math.max(MIN_VISUAL_SEGMENT_SECONDS, targetSegment.duration + secondsDelta), - ); - if (nextTargetDuration === targetSegment.duration) { - notify(delta > 0 ? "视觉轨道已经达到 30 分钟上限" : "当前视觉片段已到最短时长"); - return; - } - const nextSegments = sourceSegments.map((segment, segmentIndex) => - segmentIndex === index ? { ...segment, duration: nextTargetDuration } : segment, - ); - commitVisualSegments(nextSegments, delta > 0 ? "当前视觉片段已加长" : "当前视觉片段已缩短", index); - return; - } - - if (selectedTrack === "music") { - notify("背景音乐长度由素材决定,下一版会支持裁剪和淡入淡出"); - return; - } - - if (selectedTrack === "source") { - notify("视频原声长度由视频决定,下一版会支持分段裁剪"); - return; - } - - if (selectedTrack !== "caption") { - notify("请先选择字幕片段,再调整片段长短"); - return; - } - - if (!captionSegments.length) { - notify("当前没有字幕片段可调整"); - return; - } - - if (trackLocks.caption) { - notify("字幕轨已锁定,无法调整片段长度"); - return; - } - - const index = selectedSegmentId ? selectedSegmentIndex : focusedSegmentIndex; - const targetCaption = captionSegments[index]; - if (hasExplicitCaptionTiming(targetCaption)) { - const nextTimedSegment = captionSegments.slice(index + 1).find(hasExplicitCaptionTiming); - const maxEnd = Math.min(MAX_TIMELINE_DURATION_SECONDS, nextTimedSegment?.start ?? MAX_TIMELINE_DURATION_SECONDS); - const nextEnd = Math.max( - targetCaption.start + 0.45, - Math.min(maxEnd, targetCaption.end + (delta > 0 ? 0.6 : -0.6)), - ); - - if (Math.abs(nextEnd - targetCaption.end) < 0.001) { - notify(delta > 0 ? "当前字幕已贴近下一段" : "当前字幕已到最短时长"); - return; - } - - const nextSegments = captionSegments.map((segment, segmentIndex) => - segmentIndex === index ? { ...segment, end: nextEnd } : segment, - ); - commitCaptionSegments(nextSegments, delta > 0 ? "当前字幕片段已加长" : "当前字幕片段已缩短", index); - return; - } - - const nextSegments = captionSegments.map((segment, segmentIndex) => - segmentIndex === index - ? { ...segment, weight: Math.max(0.5, Math.min(5, (segment.weight ?? 1) + delta)) } - : segment, - ); - commitCaptionSegments(nextSegments, delta > 0 ? "当前字幕片段已加长" : "当前字幕片段已缩短", index); - } - - function toggleTrackVisibility(track) { - setTrackVisibility((visibility) => ({ - ...visibility, - [track]: !visibility[track], - })); - } - - function toggleTrackLock(track) { - setTrackLocks((locks) => ({ - ...locks, - [track]: !locks[track], - })); - } - - function useHistoryItem(item) { - replaceAudio(item.blob, item.duration, item.peaks, `${item.voiceName} 已恢复`); - setScript(item.script); - const nextSegments = createCaptionSegments(item.script); - setCaptionSegments(nextSegments); - setSelectedSegmentId(nextSegments[0]?.id ?? ""); - setSelectedVoiceId(item.voiceId); - notify("历史配音已恢复到时间线"); - } - - const progressPercent = Math.max(0, Math.min(100, progress)); - const playheadPercent = Math.max( - 0, - Math.min(100, ((currentTime || 0) / Math.max(timelineDuration, 1)) * 100), - ); - const previewRatio = `${ratio.width} / ${ratio.height}`; - const renderedVisualSegments = imageSrc - ? visualSegments.length - ? visualSegments - : [{ id: "visual-fallback", duration: imageDuration, ...getVisualAssetPayload(getCurrentVisualAssetSnapshot()) }] - : []; - const activeTimelineClipDrag = timelineClipDrag?.dragging ? timelineClipDrag : null; - const draggedAsset = draggedAssetId ? findAssetById(draggedAssetId) : null; - const showStickerTrack = - stickerSegments.length > 0 || - selectedTrack === "sticker" || - assetDropTargetTrack === "sticker" || - assetDragPreview?.type === "sticker" || - draggedAsset?.type === "sticker"; - const displayedVisualSegments = - activeTimelineClipDrag?.track === "image" - ? reorderTimelineItems( - renderedVisualSegments, - activeTimelineClipDrag.fromIndex, - activeTimelineClipDrag.overIndex, - ) - : renderedVisualSegments; - const renderedVisualTimeline = getVisualSegmentTimeline(displayedVisualSegments); - const displayedCaptionSegments = - activeTimelineClipDrag?.track === "caption" - ? reorderTimelineItems( - captionSegments, - activeTimelineClipDrag.fromIndex, - activeTimelineClipDrag.overIndex, - ) - : captionSegments; - const displayedCaptionTimeline = - activeTimelineClipDrag?.track === "caption" - ? getCaptionTimeline(displayedCaptionSegments, captionTargetDuration) - : captionTimeline; - const audioClipPercent = - audioBlob && timelineDuration > 0 - ? Math.max(0.01, Math.min(100, (audioDuration / timelineDuration) * 100)) - : 0; - const sourceAudioStartPercent = - sourceAudioBlob && timelineDuration > 0 - ? Math.max(0, Math.min(100, (sourceAudioStart / timelineDuration) * 100)) - : 0; - const sourceAudioClipPercent = - sourceAudioBlob && timelineDuration > 0 - ? Math.max( - 0.01, - Math.min(100 - sourceAudioStartPercent, (sourceAudioDuration / timelineDuration) * 100), - ) - : 0; - const musicClipPercent = - musicBlob && timelineDuration > 0 - ? Math.max(0.01, Math.min(100, (musicDuration / timelineDuration) * 100)) - : 0; - const exportPercent = Math.max(0, Math.min(100, Math.round(exportProgress))); - const previewFrameStyle = - previewFrameSize.width > 0 && previewFrameSize.height > 0 - ? { - "--preview-ratio": previewRatio, - width: `${previewFrameSize.width}px`, - height: `${previewFrameSize.height}px`, - } - : { "--preview-ratio": previewRatio }; + const { builtInAssets, filteredVoices } = useEditorCatalog(voiceFilter); + + const { + canDropAssetOnTrack, findAssetById, getActiveDraggedAsset, getDraggedAsset, + getTimelineDropPercent, handleAssetClick, handleAssetDragEnd, handleAssetDragStart, + handleAssetPointerDown, handleStickerClick, handleTrackAssetDragLeave, + handleTrackAssetDragOver, triggerAssetDropPulse, + } = createAssetDragControls({ + applyAssetToTrack: (...args) => applyAssetToTrack(...args), assetDropPulseTimerRef, builtInAssets, draggedAssetId, + draggedAssetIdRef, getStickerDragAsset, notify, pointerAssetDragRef, + setAssetDragPreview, setAssetDropPosition, setAssetDropPulseTrack, + setAssetDropTargetTrack, setDraggedAssetId, setSelectedLibraryAssetId, + setSelectedStickerId, setSelectedStickerSegmentId, suppressAssetClickRef, + t, trackLocks, trackScrollRef, userAssets, + }); + + const analyzeCurrentVisual = useVisionAnalysis({ + notify, previewVideoRef, previewVisionKey, previewVisualSegment, previewVisualSrc, + previewVisualType, setVisionJob, setVisionRecords, visionAbortControllerRef, + visionJob, visionJobGenerationRef, visionObjectUrlsRef, + }); + + const { + clearVisionAnalysis, downloadVisionCutout, removeVisionRecordsForAsset, + setFitModeFromUser, toggleVisionOption, + } = createVisionControls({ + imageName, notify, previewVisionAnalysis, previewVisionBaseAnalysis, previewVisionKey, + previewVisionOptions, previewVisionRecord, previewVisualSegment, previewVisualType, + setFitMode, setVisionJob, setVisionRecords, visionAbortControllerRef, + visionJob, visionJobGenerationRef, visionObjectUrlsRef, + }); + + const { + commitCaptionSegments, deleteCaptionSegment, handleCaptionPositionChange, + startCaptionDrag, toggleCaptionSegmentHidden, + updateCaptionSegmentText, updateScript, + } = createCaptionEditingActions({ + audioSegments, captionSegments, currentCaptionSegment, focusedSegmentIndex, + notify, previewCanvasRef, previewVisionKey, previewVisionRecord, script, + selectedSegmentId, setCaptionPlacement, setCaptionPosition, setCaptionSegments, + setScript, setSelectedSegmentId, setSelectedTrack, + setVisionRecords, trackLocks, + }); + + const { + clearAudioTrack, clearMusicTrack, clearSourceAudioTrack, commitAudio, + replaceAudio, replaceMusic, replaceSourceAudio, + } = createAudioTrackActions({ + audioBlob, audioDuration, audioSegmentRefs, audioSegments, captionDuration, + currentTimeRef, imageDuration, imageSrc, musicBlob, musicDuration, musicRef, + musicUrlRef, notify, script, selectedVoice, selectedVoiceId, setActiveTool, + setAudioSegments, setCaptionSegments, setCurrentTime, setHistoryItems, + setIsPlaying, setMusicBlob, setMusicDuration, setMusicName, setMusicPeaks, + setMusicUrl, setProgress, setSelectedAudioSegmentId, setSelectedSegmentId, + setSelectedTrack, setSourceAudioBlob, setSourceAudioDuration, setSourceAudioName, + setSourceAudioPeaks, setSourceAudioStart, setSourceAudioUrl, setSourceAudioVolume, + setStatus, setStatusText, setTimelineHorizon, sourceAudioBlob, sourceAudioDuration, + sourceAudioRef, sourceAudioStart, sourceAudioUrlRef, t, + }); + + const { + chooseInterfaceLanguage, clearAllVisionState, selectTool, toggleTrackLock, + toggleTrackVisibility, useHistoryItem, + } = createEditorCommandActions({ + notify, replaceAudio, script, setActiveTool, setAvatarPanelOpen, setCaptionSegments, + setIntroClosing, setScript, setSelectedSegmentId, setSelectedTrack, + setSelectedVoiceId, setTrackLocks, setTrackVisibility, setUiLanguage, + setVisionJob, setVisionRecords, setVoiceTab, visionAbortControllerRef, + visionJobGenerationRef, visionObjectUrlsRef, + }); + + const { + appendVisualAssetToTimeline, clearImageTrack, commitVisualSegments, + getCurrentVisualAssetSnapshot, getVisualDurationForAsset, replaceVisualTimeline, + setCurrentVisualAsset, updateVisualAssetInTimeline, + } = createVisualTimelineActions({ + audioBlob, audioDuration, captionDuration, + extractVideoSourceAudio: (...args) => extractVideoSourceAudio(...args), + imageDuration, imageMeta, imageName, imageSrc, musicBlob, musicDuration, notify, + previewVisualSegment, script, seekTo: (...args) => seekTo(...args), setCurrentTime, + setFitMode, setImageClipCount, setImageDuration, setImageMeta, setImageName, + setImageSrc, setSelectedTrack, setSelectedVisualSegmentId, setVisualSegments, + setVisualType, sourceAudioBlob, sourceAudioDuration, sourceAudioStart, trackLocks, + visualSegments, visualType, + }); + + const { + addStickerAssetToTimeline, commitStickerSegments, getTimelineTimeFromDropPercent, + } = createStickerTimelineActions({ + estimatedDuration, notify, seekTo: (...args) => seekTo(...args), setActiveTool, + setSelectedStickerId, setSelectedStickerSegmentId, setSelectedTrack, + setStickerSegments, stickerSegments, timelineDurationRef, trackLocks, + }); + + const { generateAvatarAcceptanceFrame, openAvatarPanel } = useAvatarGeneration({ + audioBlob, audioDuration, avatarJob, avatarMotionCacheRef, avatarMotionWorkerRef, + avatarRenderWorkerRef, imageDuration, imageUrlRefs, notify, previewVisualSegment, + previewVisualSrc, previewVisualType, replaceVisualTimeline, setAvatarJob, + setAvatarPanelOpen, setCurrentTime, setUserAssets, t, + }); + + const { startVoiceRecording, stopVoiceRecording, useRecordedVoice } = useVoiceRecorder({ + notify, recordingState, replaceAudio, setActiveTool, setProgress, + setRecordedVoices, setRecordingElapsed, setRecordingState, setSelectedTrack, + setStatus, setStatusText, setVoiceTab, t, voiceRecorderChunksRef, + voiceRecorderRef, voiceRecorderStartedAtRef, voiceRecorderStreamRef, + voiceRecorderTimerRef, + }); + + const generateVoiceover = useVoiceGeneration({ + commitAudio, notify, script, selectedVoice, setProgress, setStatus, + setStatusText, setVoiceTab, speed, status, t, + }); + + const { deleteAudioSegment, toggleAudioSegmentReverse, updateAudioSegment } = createAudioClipActions({ + audioSegmentRefs, audioSegments, notify, setAudioSegments, setCaptionSegments, + setSelectedAudioSegmentId, setTimelineHorizon, t, + }); + + const { handleAddSegment, handleRemoveSegment } = createTimelineSegmentCountActions({ + captionSegments, clearImageTrack, commitCaptionSegments, commitStickerSegments, + commitVisualSegments, currentStickerSegmentIndex, currentTime, + currentVisualSegmentIndex, deleteCaptionSegment, focusedSegmentIndex, + getCurrentVisualAssetSnapshot, getStickerDragAsset, imageClipCount, + imageDuration, imageSrc, notify, selectedSegmentId, selectedSegmentIndex, + selectedSticker, selectedStickerSegmentId, selectedTrack, + selectedVisualSegmentId, selectedVisualSegmentIndex, stickerSegments, + trackLocks, visualSegments, + }); + + const adjustSelectedSegmentWeight = createTimelineDurationActions({ + captionSegments, commitCaptionSegments, commitStickerSegments, + commitVisualSegments, currentStickerSegmentIndex, currentVisualSegmentIndex, + focusedSegmentIndex, getCurrentVisualAssetSnapshot, imageDuration, imageSrc, + notify, selectedSegmentId, selectedSegmentIndex, selectedStickerSegmentId, + selectedTrack, selectedVisualSegmentId, selectedVisualSegmentIndex, + stickerSegments, trackLocks, visualSegments, + }); + + const { handleDeleteTrack, handleDuplicateTrack } = createTimelineClipboardActions({ + audioBlob, captionSegments, clearImageTrack, clearMusicTrack, clearSourceAudioTrack, + commitCaptionSegments, commitStickerSegments, commitVisualSegments, + currentStickerSegmentIndex, currentVisualSegmentIndex, deleteAudioSegment, + focusedSegmentIndex, getCurrentVisualAssetSnapshot, handleRemoveSegment, + imageClipCount, imageDuration, imageMeta, imageName, imageSrc, musicBlob, musicName, + notify, selectedAudioSegment, selectedAudioSegmentId, selectedSegmentId, + selectedSegmentIndex, selectedStickerSegmentId, selectedTrack, + selectedVisualSegmentId, selectedVisualSegmentIndex, setAudioSegments, + setCaptionSegments, setSelectedAudioSegmentId, setUserAssets, sourceAudioBlob, + sourceAudioName, stickerSegments, t, trackLocks, visualSegments, visualType, + }); + + useEditorLifecycle({ + activeLanguage, audioSegments, audioUrlRef, autoRatioSourceKeyRef, + avatarMotionWorkerRef, avatarRenderWorkerRef, avatarTestAudioImportedRef, + avatarTestImportedRef, captionSegments, currentVisualSegment, handleDeleteTrack, + imageUrlRefs, musicBlob, musicUrlRef, notify, ratioId, replaceAudio, + replaceVisualTimeline, selectedAudioSegmentId, selectedSegmentId, + selectedStickerSegmentId, selectedTrack, selectedVisualSegmentId, setCurrentVisualAsset, + setFitMode, setRatioId, setSelectedSegmentId, setSelectedVisualSegmentId, + setUserAssets, sourceAudioBlob, sourceAudioUrlRef, stickerSegments, + visionAbortControllerRef, visionObjectUrlsRef, visualSegments, + voiceRecorderStreamRef, voiceRecorderTimerRef, + }); + + const { handleCutTrack } = createTimelineCutActions({ + captionSegments, commitCaptionSegments, commitStickerSegments, commitVisualSegments, + currentStickerSegmentIndex, currentTime, focusedSegmentIndex, + getCurrentVisualAssetSnapshot, imageDuration, imageSrc, notify, + selectedSegmentId, selectedSegmentIndex, selectedStickerSegmentId, + selectedTrack, stickerSegments, trackLocks, visualSegments, + }); + + const { getTimelineTimeFromClientX, handlePlayToggle, pauseTimelineMedia, seekTo, startTimelineSeek } = createPlaybackControls({ + audioSegmentRefs, audioSegments, canPreview, currentTimeRef, currentVisualRange, + estimatedDuration, isPlaying, musicDuration, musicRef, musicUrl, notify, + previewVideoRef, previewVisualType, setCurrentTime, setIsPlaying, sourceAudioDuration, + sourceAudioRef, sourceAudioStart, sourceAudioUrl, timelineDuration, + timelineDurationRef, trackScrollRef, trackVisibility, visualSegments, visualTimeline, + }); + + useMediaSync({ + audioRef, audioSegmentRefs, audioSegments, currentTime, currentTimeRef, estimatedDuration, + isPlaying, musicRef, musicUrl, musicVolume, pauseTimelineMedia, previewVideoRef, + previewVisualSegment, previewVisualSourceTime, previewVisualSrc, previewVisualType, + setCurrentTime, setIsPlaying, setPreviewVideoMediaTime, sourceAudioDuration, + sourceAudioRef, sourceAudioStart, sourceAudioUrl, sourceAudioVolume, timelineDuration, + trackVisibility, visualPlaybackFrameRef, visualPlaybackLastUpdateRef, + visualPlaybackStartedAtRef, visualPlaybackStartTimeRef, + }); + + const { startAudioSegmentMove, startStickerSegmentMove } = createTimelineMoveControls({ + audioSegments, captionSegments, estimatedDuration, notify, seekTo, setActiveTool, + setAudioSegments, setCaptionSegments, setSelectedAudioSegmentId, setSelectedStickerId, + setSelectedStickerSegmentId, setSelectedTrack, setStickerSegments, setTimelineHorizon, + stickerSegments, suppressTimelineClipClickRef, t, timelineDurationRef, + trackLocks, trackScrollRef, + }); + + const startImageResize = createImageResizeControl({ + audioBlob, audioDuration, captionDuration, getCurrentVisualAssetSnapshot, + imageDuration, imageSrc, musicBlob, musicDuration, notify, script, + setCurrentTime, setImageClipCount, setImageDuration, setSelectedTrack, + setSelectedVisualSegmentId, setSnapGuide, setVisualSegments, sourceAudioBlob, + sourceAudioDuration, sourceAudioStart, timelineDuration, timelineDurationRef, + trackLocks, trackScrollRef, visualSegments, + }); + + const extractVideoSourceAudio = useSourceAudioExtraction({ + clearSourceAudioTrack, notify, replaceSourceAudio, setProgress, setStatus, setStatusText, + }); + + const generateCaptionsFromSourceAudio = useAutoCaptions({ + notify, script, seekTo, setActiveTool, setCaptionSegments, + setCaptionsEnabled, setProgress, setScript, setSelectedSegmentId, + setSelectedTrack, setStatus, setStatusText, setTrackVisibility, sourceAudioBlob, + sourceAudioStart, status, trackLocks, uiLanguage, + }); + + const handleFiles = useFileUpload({ + imageUrlRefs, notify, setSelectedLibraryAssetId, setUserAssets, + updateVisualAssetInTimeline, + }); + + const { deleteUserAsset, selectAsset } = createAssetLibraryActions({ + clearImageTrack, clearMusicTrack, clearSourceAudioTrack, commitVisualSegments, + extractVideoSourceAudio, getVisualDurationForAsset, imageSrc, imageUrlRefs, + musicBlob, notify, removeVisionRecordsForAsset, replaceMusic, replaceVisualTimeline, + selectedLibraryAssetId, setSelectedLibraryAssetId, setUserAssets, sourceAudioBlob, + userAssets, visualSegments, + }); + + const { applyAssetToTrack, handleTrackAssetDrop, handleVisualStyleDrop } = createAssetDropActions({ + addStickerAssetToTimeline, appendVisualAssetToTimeline, canDropAssetOnTrack, + draggedAssetIdRef, extractVideoSourceAudio, getDraggedAsset, getTimelineDropPercent, + notify, replaceAudio, selectAsset, setActiveTool, setAssetDropPosition, + setAssetDropTargetTrack, setDraggedAssetId, setSelectedFilterId, + setSelectedLibraryAssetId, setSelectedTrack, setSelectedTransitionId, + setSelectedVisualSegmentId, setVisualSegments, trackScrollRef, + triggerAssetDropPulse, + }); + + const { handleExportProject, handleImportProject, handleNewProject } = useProjectFiles({ + audioBlob, audioDuration, captionPlacement, captionPosition, captionSegments, captionSize, + captionStyle, captionsEnabled, captionStyleFallback: captionStyle, clearAllVisionState, + clearAudioTrack, clearImageTrack, clearMusicTrack, clearSourceAudioTrack, fitMode, + imageUrlRefs, musicBlob, musicDuration, musicName, musicVolume, notify, projectFileInputRef, + ratioId, replaceAudio, replaceMusic, replaceSourceAudio, script, selectedFilterId, + selectedStickerId, selectedTransitionId, selectedVoiceId, setCaptionPlacement, + setCaptionPosition, setCaptionSegments, setCaptionSize, setCaptionStyle, setCaptionsEnabled, + setCurrentTime, setFitMode, setImageClipCount, setImageDuration, setMusicVolume, + setRatioId, setScript, setSelectedFilterId, setSelectedSegmentId, setSelectedStickerId, + setSelectedStickerSegmentId, setSelectedTransitionId, setSelectedVoiceId, setShowFileMenu, + setSourceAudioVolume, setSpeed, setStickerSegments, setTimelineZoom, setTrackVisibility, + setVisualSegments, setVolume, setCurrentVisualAsset, sourceAudioBlob, sourceAudioDuration, + sourceAudioName, sourceAudioStart, sourceAudioVolume, speed, stickerSegments, + timelineZoom, trackVisibility, visualSegments, volume, + }); + + const { + activeTimelineClipDrag, audioClipPercent, displayedCaptionSegments, + displayedCaptionTimeline, displayedVisualSegments, exportPercent, musicClipPercent, + playheadPercent, previewFrameStyle, previewRatio, progressPercent, + renderedVisualSegments, renderedVisualTimeline, showStickerTrack, + sourceAudioClipPercent, sourceAudioStartPercent, + } = createTimelineViewModel({ + assetDragPreview, assetDropTargetTrack, audioBlob, audioDuration, captionSegments, + captionTargetDuration, captionTimeline, currentTime, draggedAssetId, exportProgress, + findAssetById, getCurrentVisualAssetSnapshot, imageDuration, imageSrc, musicBlob, + musicDuration, previewFrameSize, progress, ratio, selectedTrack, sourceAudioBlob, + sourceAudioDuration, sourceAudioStart, stickerSegments, timelineClipDrag, + timelineDuration, visualSegments, + }); + const handleExportVideo = useVideoExport({ + audioSegments, captionDuration, captionPlacement, captionPosition, captionSegments, + captionSize, captionStyle, captionsEnabled, exporting, exportStartRef, fitMode, + imageDuration, imageSrc, musicBlob, musicDuration, musicVolume, notify, + previewFrameSize, ratio, renderedVisualSegments, script, selectedFilter, + selectedSticker, selectedTransitionId, setExporting, setExportPhase, + setExportProgress, setStatus, setStatusText, sourceAudioBlob, sourceAudioDuration, + sourceAudioStart, sourceAudioVolume, stickerDuration, stickerSegments, + trackVisibility, visionRecords, visualType, voiceTrackDuration, volume, + }); + const { startTimelineClipDrag } = createTimelineReorderControls({ + captionSegments, captionTargetDuration, commitCaptionSegments, commitVisualSegments, + notify, renderedVisualSegments, seekTo, setSelectedSegmentId, setSelectedTrack, + setSelectedVisualSegmentId, setTimelineClipDrag, suppressTimelineClipClickRef, + timelineClipDragRef, trackLocks, visualSegments, + }); return (
@@ -4833,111 +522,26 @@ export function App() { />
- - - + - {assetDragPreview ? ( -
- {assetDragPreview.src ? ( -
- {assetDragPreview.type === "video" ? ( -
- ) : null} - - {assetDragPreview.type === "audio" - ? t("assetAudio") - : assetDragPreview.type === "video" - ? t("assetVideo") - : assetDragPreview.type === "sticker" - ? t("assetSticker") - : t("assetImage")} - - {assetDragPreview.name} -
- ) : null} - - {exporting ? ( -
-
-
- {t("exportInProgress")} - {exportPercent}% -
-
- -
-
- {exportPhase || t("preparingExport")} - {formatClock(exportElapsedSeconds)} -
-
-
- ) : null} + + {shouldShowLanguageIntro ? ( ) : null} diff --git a/src/components/EditorOverlays.jsx b/src/components/EditorOverlays.jsx new file mode 100644 index 0000000..a6e5e3b --- /dev/null +++ b/src/components/EditorOverlays.jsx @@ -0,0 +1,24 @@ +import { formatClock } from "../lib/timeline.js"; + +export function AssetDragPreview({ preview, t }) { + if (!preview) return null; + const label = preview.type === "audio" ? t("assetAudio") : preview.type === "video" ? t("assetVideo") : preview.type === "sticker" ? t("assetSticker") : t("assetImage"); + return
+ {preview.src ?
+ {preview.type === "video" ?
: null} + {label}{preview.name} +
; +} + +export function ExportProgressOverlay({ exporting, percent, phase, elapsedSeconds, t }) { + if (!exporting) return null; + return
+
{t("exportInProgress")}{percent}%
+
+ +
+
{phase || t("preparingExport")}{formatClock(elapsedSeconds)}
+
; +} diff --git a/src/components/EditorSidebar.jsx b/src/components/EditorSidebar.jsx new file mode 100644 index 0000000..6b085d8 --- /dev/null +++ b/src/components/EditorSidebar.jsx @@ -0,0 +1,114 @@ +import { TOOL_RAIL } from "../config/editor.js"; +import { MediaPanel, ToolPanel } from "./panels.jsx"; + +export function EditorSidebar({ model: d }) { + return ( + <> + + + + + ); +} diff --git a/src/components/panels.jsx b/src/components/panels.jsx index ee16b6a..b8e7b07 100644 --- a/src/components/panels.jsx +++ b/src/components/panels.jsx @@ -1135,7 +1135,7 @@ export function MyVoicesPanel({ recordingElapsed, startVoiceRecording, stopVoiceRecording, - useRecordedVoice, + useRecordedVoice: onUseRecordedVoice, downloadBlob, }) { const favorites = VOICES.filter((voice) => favoriteVoiceIds.includes(voice.id)); @@ -1170,7 +1170,7 @@ export function MyVoicesPanel({ {recording.createdAt} · {formatTime(recording.duration)} -